@growth-labs/cms 0.8.26 → 0.8.30

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 (85) hide show
  1. package/README.md +23 -0
  2. package/dist/engine/content-insights.d.ts +27 -3
  3. package/dist/engine/content-insights.d.ts.map +1 -1
  4. package/dist/engine/content-insights.js +83 -4
  5. package/dist/engine/content-insights.js.map +1 -1
  6. package/dist/engine/index.d.ts +1 -0
  7. package/dist/engine/index.d.ts.map +1 -1
  8. package/dist/engine/index.js +1 -0
  9. package/dist/engine/index.js.map +1 -1
  10. package/dist/engine/publication-receipt/receipt.d.ts +4 -4
  11. package/dist/engine/published-content.d.ts +4 -0
  12. package/dist/engine/published-content.d.ts.map +1 -1
  13. package/dist/engine/published-content.js +52 -16
  14. package/dist/engine/published-content.js.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/integration/options.d.ts +226 -13
  20. package/dist/integration/options.d.ts.map +1 -1
  21. package/dist/integration/options.js +17 -2
  22. package/dist/integration/options.js.map +1 -1
  23. package/dist/providers/types.d.ts +10 -0
  24. package/dist/providers/types.d.ts.map +1 -1
  25. package/dist/providers/types.js.map +1 -1
  26. package/dist/routes/content-insights.d.ts +4 -0
  27. package/dist/routes/content-insights.d.ts.map +1 -1
  28. package/dist/routes/content-insights.js +137 -3
  29. package/dist/routes/content-insights.js.map +1 -1
  30. package/dist/routes/fronts-publish.d.ts +1 -1
  31. package/dist/routes/fronts-publish.d.ts.map +1 -1
  32. package/dist/routes/fronts-publish.js +2 -2
  33. package/dist/routes/fronts-publish.js.map +1 -1
  34. package/dist/routes/surveys.d.ts.map +1 -1
  35. package/dist/routes/surveys.js +80 -4
  36. package/dist/routes/surveys.js.map +1 -1
  37. package/dist/schema/insights-ingest.d.ts +961 -97
  38. package/dist/schema/insights-ingest.d.ts.map +1 -1
  39. package/dist/schema/insights-ingest.js +38 -0
  40. package/dist/schema/insights-ingest.js.map +1 -1
  41. package/dist/schema/types.d.ts +20 -0
  42. package/dist/schema/types.d.ts.map +1 -1
  43. package/dist/schema/types.js.map +1 -1
  44. package/dist/surveys/auto-label.d.ts +31 -4
  45. package/dist/surveys/auto-label.d.ts.map +1 -1
  46. package/dist/surveys/auto-label.js +160 -29
  47. package/dist/surveys/auto-label.js.map +1 -1
  48. package/dist/surveys/schema.d.ts +155 -0
  49. package/dist/surveys/schema.d.ts.map +1 -1
  50. package/dist/surveys/schema.js +49 -0
  51. package/dist/surveys/schema.js.map +1 -1
  52. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
  53. package/dist/ui/api/content/insights-inputs.js +11 -1
  54. package/dist/ui/api/content/insights-inputs.js.map +1 -1
  55. package/dist/ui/client/mount.d.ts.map +1 -1
  56. package/dist/ui/client/mount.js +1 -0
  57. package/dist/ui/client/mount.js.map +1 -1
  58. package/dist/ui/screens/SurveyResponsesScreen.d.ts +18 -3
  59. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  60. package/dist/ui/screens/SurveyResponsesScreen.js +75 -18
  61. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  62. package/dist/ui/screens/registry.d.ts +3 -1
  63. package/dist/ui/screens/registry.d.ts.map +1 -1
  64. package/dist/ui/screens/registry.js +4 -2
  65. package/dist/ui/screens/registry.js.map +1 -1
  66. package/dist/ui/styles/broadsheet.css +1 -0
  67. package/package.json +1 -1
  68. package/src/engine/content-insights.ts +99 -6
  69. package/src/engine/index.ts +1 -0
  70. package/src/engine/published-content.ts +75 -16
  71. package/src/index.ts +1 -0
  72. package/src/integration/options.ts +21 -1
  73. package/src/providers/types.ts +9 -0
  74. package/src/routes/content-insights.ts +144 -2
  75. package/src/routes/fronts-publish.ts +2 -1
  76. package/src/routes/surveys.ts +106 -4
  77. package/src/schema/insights-ingest.ts +45 -0
  78. package/src/schema/types.ts +10 -0
  79. package/src/surveys/auto-label.ts +204 -33
  80. package/src/surveys/schema.ts +58 -0
  81. package/src/ui/api/content/insights-inputs.ts +8 -1
  82. package/src/ui/client/mount.tsx +1 -0
  83. package/src/ui/screens/SurveyResponsesScreen.tsx +198 -18
  84. package/src/ui/screens/registry.tsx +6 -2
  85. package/src/ui/styles/broadsheet.css +1 -0
@@ -611,6 +611,16 @@ export interface InsightBrief {
611
611
  cluster: {
612
612
  canonical: string
613
613
  spokes: string[]
614
+ selected?: {
615
+ site_id: string
616
+ run_id: string
617
+ intent_key: string
618
+ desk: string
619
+ decision_ref: string
620
+ taxonomy_ref: string
621
+ hub: { content_id: string; revision_id: string; url: string; title: string }
622
+ children: Array<{ content_id: string; revision_id: string; url: string; title: string }>
623
+ }
614
624
  }
615
625
  sources: Array<{ title: string; url: string }>
616
626
  format?: 'cornerstone' | 'pillar' | 'standalone'
@@ -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 {
@@ -196,6 +196,38 @@ export const SurveyQuestionSchema = z
196
196
  }
197
197
  })
198
198
 
199
+ /** Reviewed delivery notes supplied by a site, never generated from private answers. */
200
+ export const SurveyFeedbackThemeSchema = z
201
+ .object({
202
+ id: SurveyIdSchema,
203
+ title: z.string().trim().min(1).max(160),
204
+ state: z.enum(['open', 'partly_shipped', 'shipped', 'unknown']),
205
+ summary: z.string().trim().min(1).max(1_000),
206
+ reviewedAt: IsoTimestampSchema,
207
+ evidence: z
208
+ .array(
209
+ z
210
+ .object({
211
+ label: z.string().trim().min(1).max(160),
212
+ href: z
213
+ .string()
214
+ .url()
215
+ .max(2_000)
216
+ .refine((value) => value.startsWith('https://')),
217
+ })
218
+ .strict(),
219
+ )
220
+ .max(6),
221
+ })
222
+ .strict()
223
+ export const SurveyFeedbackThemesSchema = z
224
+ .array(SurveyFeedbackThemeSchema)
225
+ .max(25)
226
+ .refine((themes) => new Set(themes.map(({ id }) => id)).size === themes.length, {
227
+ message: 'Feedback theme IDs must be unique',
228
+ })
229
+ export type SurveyFeedbackTheme = z.infer<typeof SurveyFeedbackThemeSchema>
230
+
199
231
  export const SurveyResultsSchema = z
200
232
  .object({
201
233
  survey: z
@@ -244,6 +276,28 @@ export const SurveyResultsSchema = z
244
276
  .object({
245
277
  questionId: SurveyQuestionIdSchema,
246
278
  value: AnswerValueSchema,
279
+ writtenValue: z
280
+ .string()
281
+ .max(
282
+ Math.max(
283
+ MAX_FREE_TEXT_LENGTH,
284
+ MAX_CHOICE_OPTIONS * (MAX_OPTION_LENGTH + 1),
285
+ ),
286
+ )
287
+ .optional(),
288
+ label: z.enum(['technical', 'general']).optional(),
289
+ labelSource: z.enum(['owner', 'auto', 'import']).optional(),
290
+ classificationStatus: z
291
+ .enum([
292
+ 'pending',
293
+ 'retry',
294
+ 'expired',
295
+ 'manual_review',
296
+ 'unavailable',
297
+ 'queued',
298
+ ])
299
+ .optional(),
300
+ nextAttemptAt: z.number().nonnegative().optional(),
247
301
  })
248
302
  .strict(),
249
303
  )
@@ -269,6 +323,10 @@ export const SurveyResultsSchema = z
269
323
  // Owner payloads carry it; non-owner entries pass through
270
324
  // undecorated, exactly as they do for `label`.
271
325
  labelSource: z.enum(['owner', 'auto', 'import']).optional(),
326
+ classificationStatus: z
327
+ .enum(['pending', 'retry', 'expired', 'manual_review', 'unavailable', 'queued'])
328
+ .optional(),
329
+ nextAttemptAt: z.number().nonnegative().optional(),
272
330
  })
273
331
  .strict(),
274
332
  )
@@ -4,10 +4,13 @@
4
4
  // X-Foundry-Timestamp + X-Foundry-Signature family as fronts/schedule) for
5
5
  // foundryd's sitebrain.insights.run schedule (W1-07 PR 6). NOT behind
6
6
  // requirePublisher. Sibling of insights-ingest under /content/.
7
+
8
+ import { config } from 'virtual:growth-labs/cms/config'
7
9
  import type { APIContext } from 'astro'
8
10
  import { createContentInsightsRoutes } from '../../../routes/content-insights.js'
9
11
  import type { RouteContext } from '../../../routes/context.js'
10
12
  import { buildSiteAuthz } from '../_authz.js'
13
+ import { toContentConfig } from '../_content-config.js'
11
14
 
12
15
  function toCtx(c: APIContext): RouteContext {
13
16
  const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
@@ -16,7 +19,11 @@ function toCtx(c: APIContext): RouteContext {
16
19
 
17
20
  function siteId(c: APIContext): string {
18
21
  const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
19
- return (env.ACTIVE_WORKSPACE_ID as string | undefined) ?? 'default'
22
+ return (
23
+ toContentConfig(c).activeWorkspaceId ??
24
+ (env.ACTIVE_WORKSPACE_ID as string | undefined) ??
25
+ (config.workspaces?.length === 1 ? config.workspaces[0]!.id : 'default')
26
+ )
20
27
  }
21
28
 
22
29
  export const GET = (c: APIContext) =>
@@ -42,6 +42,7 @@ export async function mount(elementId = 'masthead-root') {
42
42
  primaryTopic: config.primaryTopic,
43
43
  contentMetadataSelects: config.contentMetadataSelects,
44
44
  surveyId: config.surveyResults?.surveyId,
45
+ surveys: config.surveyResults?.surveys,
45
46
  })
46
47
  createRoot(el).render(<CmsApp initial={initial} screens={screens} boot={boot} />)
47
48
  }