@growth-labs/cms 0.8.24 → 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 (79) 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 +61 -19
  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 +17 -1
  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/schema/migrations.d.ts.map +1 -1
  26. package/dist/schema/migrations.js +7 -4
  27. package/dist/schema/migrations.js.map +1 -1
  28. package/dist/surveys/auto-label.d.ts +31 -4
  29. package/dist/surveys/auto-label.d.ts.map +1 -1
  30. package/dist/surveys/auto-label.js +160 -29
  31. package/dist/surveys/auto-label.js.map +1 -1
  32. package/dist/surveys/readout.d.ts +69 -0
  33. package/dist/surveys/readout.d.ts.map +1 -0
  34. package/dist/surveys/readout.js +157 -0
  35. package/dist/surveys/readout.js.map +1 -0
  36. package/dist/surveys/schema.d.ts +156 -0
  37. package/dist/surveys/schema.d.ts.map +1 -1
  38. package/dist/surveys/schema.js +50 -1
  39. package/dist/surveys/schema.js.map +1 -1
  40. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
  41. package/dist/ui/api/content/insights-inputs.js +11 -1
  42. package/dist/ui/api/content/insights-inputs.js.map +1 -1
  43. package/dist/ui/client/mount.d.ts.map +1 -1
  44. package/dist/ui/client/mount.js +1 -0
  45. package/dist/ui/client/mount.js.map +1 -1
  46. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -1
  47. package/dist/ui/screens/PodcastListeningScreen.js +16 -2
  48. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -1
  49. package/dist/ui/screens/SurveyResponsesScreen.d.ts +36 -3
  50. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  51. package/dist/ui/screens/SurveyResponsesScreen.js +271 -107
  52. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  53. package/dist/ui/screens/podcast-listening-data.d.ts +1 -1
  54. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -1
  55. package/dist/ui/screens/podcast-listening-data.js +26 -1
  56. package/dist/ui/screens/podcast-listening-data.js.map +1 -1
  57. package/dist/ui/screens/registry.d.ts +3 -1
  58. package/dist/ui/screens/registry.d.ts.map +1 -1
  59. package/dist/ui/screens/registry.js +4 -2
  60. package/dist/ui/screens/registry.js.map +1 -1
  61. package/dist/ui/styles/broadsheet.css +136 -0
  62. package/package.json +2 -3
  63. package/src/engine/published-content.ts +86 -19
  64. package/src/index.ts +1 -0
  65. package/src/integration/options.ts +21 -1
  66. package/src/providers/types.ts +16 -1
  67. package/src/routes/content-insights.ts +104 -0
  68. package/src/routes/surveys.ts +106 -4
  69. package/src/schema/migrations.ts +7 -4
  70. package/src/surveys/auto-label.ts +204 -33
  71. package/src/surveys/readout.ts +243 -0
  72. package/src/surveys/schema.ts +59 -1
  73. package/src/ui/api/content/insights-inputs.ts +8 -1
  74. package/src/ui/client/mount.tsx +1 -0
  75. package/src/ui/screens/PodcastListeningScreen.tsx +30 -0
  76. package/src/ui/screens/SurveyResponsesScreen.tsx +964 -305
  77. package/src/ui/screens/podcast-listening-data.ts +33 -1
  78. package/src/ui/screens/registry.tsx +6 -2
  79. package/src/ui/styles/broadsheet.css +136 -0
@@ -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 {
@@ -0,0 +1,243 @@
1
+ import type { SurveyQuestion, SurveyResults } from './schema.js'
2
+
3
+ // Pure readout math for the survey results screen. Every share here names its
4
+ // denominator: a question's share is of the people who answered it, and a
5
+ // group's share is of that group's members who answered it — never of a page.
6
+
7
+ export type SurveyResponse = SurveyResults['responses'][number]
8
+ type ScaleQuestion = Extract<SurveyQuestion, { kind: 'scale' }>
9
+ type ChoiceQuestion = Extract<SurveyQuestion, { kind: 'single_choice' | 'multi_choice' }>
10
+
11
+ export type Mood = 'unhappy' | 'neutral' | 'happy'
12
+
13
+ export interface ScaleSummary {
14
+ min: number
15
+ max: number
16
+ answered: number
17
+ average: number | null
18
+ happy: number
19
+ neutral: number
20
+ unhappy: number
21
+ happyRange: string
22
+ unhappyRange: string
23
+ /** The midpoint value on an odd-length scale; null when the scale has no middle. */
24
+ neutralValue: number | null
25
+ }
26
+
27
+ export function scaleMood(value: number, min: number, max: number): Mood {
28
+ const midpoint = (min + max) / 2
29
+ if (value > midpoint) return 'happy'
30
+ if (value < midpoint) return 'unhappy'
31
+ return 'neutral'
32
+ }
33
+
34
+ export function summarizeScale(
35
+ options: { min: number; max: number },
36
+ distribution: Record<string, number>,
37
+ ): ScaleSummary {
38
+ const { min, max } = options
39
+ const midpoint = (min + max) / 2
40
+ let answered = 0
41
+ let sum = 0
42
+ const totals = { happy: 0, neutral: 0, unhappy: 0 }
43
+ for (let value = min; value <= max; value += 1) {
44
+ const count = distribution[String(value)] ?? 0
45
+ answered += count
46
+ sum += value * count
47
+ totals[scaleMood(value, min, max)] += count
48
+ }
49
+ return {
50
+ min,
51
+ max,
52
+ answered,
53
+ average: answered === 0 ? null : sum / answered,
54
+ ...totals,
55
+ happyRange: valueRange(Math.floor(midpoint) + 1, max),
56
+ unhappyRange: valueRange(min, Math.ceil(midpoint) - 1),
57
+ neutralValue: Number.isInteger(midpoint) ? midpoint : null,
58
+ }
59
+ }
60
+
61
+ export function share(count: number, of: number): number {
62
+ return of === 0 ? 0 : count / of
63
+ }
64
+
65
+ export function percent(count: number, of: number): string {
66
+ return `${Math.round(share(count, of) * 100)}%`
67
+ }
68
+
69
+ export interface OptionShare {
70
+ option: string
71
+ count: number
72
+ }
73
+
74
+ // A multi-choice question is a ranking ("which two would you most want"), so it
75
+ // reads best sorted; a single choice keeps its configured order, which is often
76
+ // ordinal (daily → rarely) and must not be reshuffled.
77
+ export function optionRows(question: ChoiceQuestion): OptionShare[] {
78
+ const rows = question.options.map((option) => ({ option, count: question.counts[option] ?? 0 }))
79
+ return question.kind === 'multi_choice' ? [...rows].sort((a, b) => b.count - a.count) : rows
80
+ }
81
+
82
+ export function questionTakeaway(question: SurveyQuestion, responseCount: number): string {
83
+ if (question.answerCount === 0) return 'Nobody has answered this yet.'
84
+ if (question.kind === 'scale') {
85
+ const summary = summarizeScale(question.options, question.distribution)
86
+ return `${percent(summary.happy, summary.answered)} rated it ${summary.happyRange} · average ${formatAverage(summary.average)} out of ${summary.max}`
87
+ }
88
+ if (question.kind === 'text') {
89
+ return `${question.answerCount} of ${responseCount} people wrote an answer (${percent(question.answerCount, responseCount)})`
90
+ }
91
+ const rows = [...optionRows(question)].sort((a, b) => b.count - a.count)
92
+ const [first, second] = rows
93
+ if (question.kind === 'multi_choice') {
94
+ return second && second.count > 0
95
+ ? `Most picked: “${first.option}” (${percent(first.count, question.answerCount)}), then “${second.option}” (${percent(second.count, question.answerCount)})`
96
+ : `Most picked: “${first.option}” (${percent(first.count, question.answerCount)})`
97
+ }
98
+ return `${percent(first.count, question.answerCount)} chose “${first.option}”`
99
+ }
100
+
101
+ export function formatAverage(average: number | null): string {
102
+ return average === null ? '—' : average.toFixed(2)
103
+ }
104
+
105
+ /** The first scale question is the survey's headline number. */
106
+ export function headlineScale(questions: SurveyQuestion[]): ScaleQuestion | null {
107
+ const scale = questions.find((question) => question.kind === 'scale')
108
+ return scale?.kind === 'scale' ? scale : null
109
+ }
110
+
111
+ export function answerValue(response: SurveyResponse, questionId: string): unknown {
112
+ return response.answers?.find((answer) => answer.questionId === questionId)?.value
113
+ }
114
+
115
+ // ── Comparing groups ────────────────────────────────────────────────────
116
+
117
+ export const MEMBERSHIP_SPLIT = 'membership'
118
+
119
+ export interface ResponseGroup {
120
+ key: string
121
+ label: string
122
+ responses: SurveyResponse[]
123
+ /** Set when the groups are moods: they then wear the mood colors, not group hues. */
124
+ mood?: Mood
125
+ }
126
+
127
+ /** Questions that can divide readers into groups: one answer per person. */
128
+ export function splitQuestions(questions: SurveyQuestion[]): SurveyQuestion[] {
129
+ return questions.filter(
130
+ (question) => question.kind === 'single_choice' || question.kind === 'scale',
131
+ )
132
+ }
133
+
134
+ export function groupResponses(
135
+ responses: SurveyResponse[],
136
+ split: string,
137
+ questions: SurveyQuestion[],
138
+ ): ResponseGroup[] {
139
+ if (split === MEMBERSHIP_SPLIT) {
140
+ return nonEmpty([
141
+ group('member', 'Members', responses, (r) => r.respondentSegment === 'member'),
142
+ group('non_member', 'Non-members', responses, (r) => r.respondentSegment === 'non_member'),
143
+ group('unknown', 'Membership unknown', responses, (r) => r.respondentSegment === 'unknown'),
144
+ ])
145
+ }
146
+ const question = questions.find((candidate) => candidate.id === split)
147
+ if (question?.kind === 'single_choice') {
148
+ return nonEmpty(
149
+ question.options.map((option) =>
150
+ group(option, option, responses, (r) => answerValue(r, question.id) === option),
151
+ ),
152
+ )
153
+ }
154
+ if (question?.kind === 'scale') {
155
+ // Six separate satisfaction groups are too thin to read; editors ask
156
+ // "what do unhappy readers want", so a scale splits by mood.
157
+ const { min, max } = question.options
158
+ const summary = summarizeScale(question.options, {})
159
+ const moodOf = (r: SurveyResponse) => {
160
+ const value = answerValue(r, question.id)
161
+ return typeof value === 'number' ? scaleMood(value, min, max) : null
162
+ }
163
+ const labels: Record<Mood, string> = {
164
+ unhappy: `Unhappy (${summary.unhappyRange})`,
165
+ neutral: `In the middle (${summary.neutralValue ?? ''})`,
166
+ happy: `Happy (${summary.happyRange})`,
167
+ }
168
+ return nonEmpty(
169
+ (['unhappy', 'neutral', 'happy'] as const).map((mood) => ({
170
+ ...group(mood, labels[mood], responses, (r) => moodOf(r) === mood),
171
+ mood,
172
+ })),
173
+ )
174
+ }
175
+ return []
176
+ }
177
+
178
+ export interface GroupCount {
179
+ count: number
180
+ answered: number
181
+ }
182
+
183
+ export type QuestionComparison =
184
+ | { kind: 'choice'; rows: Array<{ option: string; byGroup: GroupCount[] }> }
185
+ | { kind: 'scale'; byGroup: ScaleSummary[] }
186
+ | { kind: 'none' }
187
+
188
+ export function compareQuestion(
189
+ question: SurveyQuestion,
190
+ groups: ResponseGroup[],
191
+ ): QuestionComparison {
192
+ if (question.kind === 'single_choice' || question.kind === 'multi_choice') {
193
+ const valuesByGroup = groups.map((g) =>
194
+ g.responses
195
+ .map((r) => answerValue(r, question.id))
196
+ .filter((value) => value !== undefined)
197
+ .map((value) => (Array.isArray(value) ? value : [value])),
198
+ )
199
+ return {
200
+ kind: 'choice',
201
+ rows: optionRows(question).map(({ option }) => ({
202
+ option,
203
+ byGroup: valuesByGroup.map((answers) => ({
204
+ count: answers.filter((picked) => picked.includes(option)).length,
205
+ answered: answers.length,
206
+ })),
207
+ })),
208
+ }
209
+ }
210
+ if (question.kind === 'scale') {
211
+ return {
212
+ kind: 'scale',
213
+ byGroup: groups.map((g) => {
214
+ const distribution: Record<string, number> = {}
215
+ for (const r of g.responses) {
216
+ const value = answerValue(r, question.id)
217
+ if (typeof value === 'number') {
218
+ distribution[String(value)] = (distribution[String(value)] ?? 0) + 1
219
+ }
220
+ }
221
+ return summarizeScale(question.options, distribution)
222
+ }),
223
+ }
224
+ }
225
+ return { kind: 'none' }
226
+ }
227
+
228
+ function group(
229
+ key: string,
230
+ label: string,
231
+ responses: SurveyResponse[],
232
+ matches: (response: SurveyResponse) => boolean,
233
+ ): ResponseGroup {
234
+ return { key, label, responses: responses.filter(matches) }
235
+ }
236
+
237
+ function nonEmpty(groups: ResponseGroup[]): ResponseGroup[] {
238
+ return groups.filter((g) => g.responses.length > 0)
239
+ }
240
+
241
+ function valueRange(from: number, to: number): string {
242
+ return from === to ? String(from) : `${from}–${to}`
243
+ }
@@ -5,7 +5,7 @@ const MAX_TITLE_LENGTH = 300
5
5
  const MAX_PROMPT_LENGTH = 1_000
6
6
  const MAX_QUESTIONS = 100
7
7
  const MAX_RESPONSES = 500
8
- const MAX_FREE_TEXT_ENTRIES = 500
8
+ export const MAX_FREE_TEXT_ENTRIES = 500
9
9
  const MAX_FREE_TEXT_LENGTH = 20_000
10
10
  const MAX_CHOICE_OPTIONS = 100
11
11
  const MAX_OPTION_LENGTH = 256
@@ -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) =>