@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.
- 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 +52 -16
- 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 +10 -0
- 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/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/schema.d.ts +155 -0
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +49 -0
- 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/SurveyResponsesScreen.d.ts +18 -3
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +75 -18
- package/dist/ui/screens/SurveyResponsesScreen.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 +1 -0
- package/package.json +1 -1
- package/src/engine/published-content.ts +75 -16
- package/src/index.ts +1 -0
- package/src/integration/options.ts +21 -1
- package/src/providers/types.ts +9 -0
- package/src/routes/content-insights.ts +104 -0
- package/src/routes/surveys.ts +106 -4
- package/src/surveys/auto-label.ts +204 -33
- package/src/surveys/schema.ts +58 -0
- package/src/ui/api/content/insights-inputs.ts +8 -1
- package/src/ui/client/mount.tsx +1 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +198 -18
- package/src/ui/screens/registry.tsx +6 -2
- 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({
|
package/src/providers/types.ts
CHANGED
|
@@ -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
|
package/src/routes/surveys.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
?
|
|
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(
|
|
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
|
|
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
|
|
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 {
|