@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.
- package/README.md +23 -0
- package/dist/engine/published-content.d.ts +4 -0
- package/dist/engine/published-content.d.ts.map +1 -1
- package/dist/engine/published-content.js +61 -19
- package/dist/engine/published-content.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/integration/options.d.ts +226 -13
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +17 -2
- package/dist/integration/options.js.map +1 -1
- package/dist/providers/types.d.ts +17 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/content-insights.d.ts +4 -0
- package/dist/routes/content-insights.d.ts.map +1 -1
- package/dist/routes/content-insights.js +104 -0
- package/dist/routes/content-insights.js.map +1 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +80 -4
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +64 -64
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +7 -4
- package/dist/schema/migrations.js.map +1 -1
- package/dist/surveys/auto-label.d.ts +31 -4
- package/dist/surveys/auto-label.d.ts.map +1 -1
- package/dist/surveys/auto-label.js +160 -29
- package/dist/surveys/auto-label.js.map +1 -1
- package/dist/surveys/readout.d.ts +69 -0
- package/dist/surveys/readout.d.ts.map +1 -0
- package/dist/surveys/readout.js +157 -0
- package/dist/surveys/readout.js.map +1 -0
- package/dist/surveys/schema.d.ts +156 -0
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +50 -1
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
- package/dist/ui/api/content/insights-inputs.js +11 -1
- package/dist/ui/api/content/insights-inputs.js.map +1 -1
- package/dist/ui/client/mount.d.ts.map +1 -1
- package/dist/ui/client/mount.js +1 -0
- package/dist/ui/client/mount.js.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.js +16 -2
- package/dist/ui/screens/PodcastListeningScreen.js.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +36 -3
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +271 -107
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -1
- package/dist/ui/screens/podcast-listening-data.js +26 -1
- package/dist/ui/screens/podcast-listening-data.js.map +1 -1
- package/dist/ui/screens/registry.d.ts +3 -1
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +4 -2
- package/dist/ui/screens/registry.js.map +1 -1
- package/dist/ui/styles/broadsheet.css +136 -0
- package/package.json +2 -3
- package/src/engine/published-content.ts +86 -19
- package/src/index.ts +1 -0
- package/src/integration/options.ts +21 -1
- package/src/providers/types.ts +16 -1
- package/src/routes/content-insights.ts +104 -0
- package/src/routes/surveys.ts +106 -4
- package/src/schema/migrations.ts +7 -4
- package/src/surveys/auto-label.ts +204 -33
- package/src/surveys/readout.ts +243 -0
- package/src/surveys/schema.ts +59 -1
- package/src/ui/api/content/insights-inputs.ts +8 -1
- package/src/ui/client/mount.tsx +1 -0
- package/src/ui/screens/PodcastListeningScreen.tsx +30 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +964 -305
- package/src/ui/screens/podcast-listening-data.ts +33 -1
- package/src/ui/screens/registry.tsx +6 -2
- 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
|
|
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
|
-
|
|
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), {
|
|
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
|
-
| {
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
362
|
-
.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
+
}
|
package/src/surveys/schema.ts
CHANGED
|
@@ -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 (
|
|
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) =>
|