@growth-labs/cms 0.8.26 → 0.8.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/engine/content-insights.d.ts +27 -3
- package/dist/engine/content-insights.d.ts.map +1 -1
- package/dist/engine/content-insights.js +83 -4
- package/dist/engine/content-insights.js.map +1 -1
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication-receipt/receipt.d.ts +4 -4
- 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 +137 -3
- package/dist/routes/content-insights.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +1 -1
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js +2 -2
- package/dist/routes/fronts-publish.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 +961 -97
- package/dist/schema/insights-ingest.d.ts.map +1 -1
- package/dist/schema/insights-ingest.js +38 -0
- package/dist/schema/insights-ingest.js.map +1 -1
- package/dist/schema/types.d.ts +20 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.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/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/content-insights.ts +99 -6
- package/src/engine/index.ts +1 -0
- 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 +144 -2
- package/src/routes/fronts-publish.ts +2 -1
- package/src/routes/surveys.ts +106 -4
- package/src/schema/insights-ingest.ts +45 -0
- package/src/schema/types.ts +10 -0
- 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
|
@@ -12,8 +12,8 @@ import { TagInputError, validateRevisionTaxonomy } from './taxonomy.js'
|
|
|
12
12
|
const CONTENT_TYPES = new Set<ContentType>(['article', 'video', 'podcast', 'newsletter', 'page'])
|
|
13
13
|
const CONTENT_STATUSES = new Set(['draft', 'scheduled', 'review', 'published', 'archived'])
|
|
14
14
|
const CONTENT_VISIBILITIES = new Set(['free', 'premium'])
|
|
15
|
-
const LIST_OPTION_KEYS = new Set(['type', 'channel', 'limit', 'cursor', 'bodies'])
|
|
16
|
-
const CURSOR_KEYS = new Set(['publishedAt', 'contentId'])
|
|
15
|
+
const LIST_OPTION_KEYS = new Set(['type', 'channel', 'limit', 'cursor', 'bodies', 'order'])
|
|
16
|
+
const CURSOR_KEYS = new Set(['publishedAt', 'contentId', 'contentDate'])
|
|
17
17
|
|
|
18
18
|
export const MAX_PUBLISHED_CONTENT_PAGE_SIZE = 100
|
|
19
19
|
export const MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES = 4 * 1024 * 1024
|
|
@@ -71,6 +71,8 @@ export interface PublishedContent {
|
|
|
71
71
|
export interface PublishedContentCursor {
|
|
72
72
|
publishedAt: number
|
|
73
73
|
contentId: string
|
|
74
|
+
/** Required when paging by the immutable snapshot's display date. */
|
|
75
|
+
contentDate?: number
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
export interface ListPublishedContentOptions {
|
|
@@ -78,6 +80,8 @@ export interface ListPublishedContentOptions {
|
|
|
78
80
|
channel?: string
|
|
79
81
|
limit?: number
|
|
80
82
|
cursor?: PublishedContentCursor | null
|
|
83
|
+
/** Preserve canonical order by default; card lists may order by snapshot date. */
|
|
84
|
+
order?: 'published' | 'content-date'
|
|
81
85
|
/**
|
|
82
86
|
* `false` strips `content.body_markdown` / `content.body_html` from each
|
|
83
87
|
* payload IN SQL, so corpus snapshots (lists, navs, hubs) stop hauling the
|
|
@@ -110,6 +114,7 @@ interface PublishedContentOutlineRow {
|
|
|
110
114
|
pointer_revision_id: string
|
|
111
115
|
canonical_published_at: number
|
|
112
116
|
payload_bytes: number
|
|
117
|
+
content_date?: number
|
|
113
118
|
}
|
|
114
119
|
|
|
115
120
|
const SELECT_PUBLISHED_CONTENT = `
|
|
@@ -149,23 +154,22 @@ SELECT
|
|
|
149
154
|
FROM content_items ci
|
|
150
155
|
`
|
|
151
156
|
|
|
152
|
-
|
|
157
|
+
function publishedOutlineSelect(bodies: boolean, contentDate: string): string {
|
|
158
|
+
return `
|
|
153
159
|
SELECT
|
|
154
160
|
ci.id AS content_id,
|
|
155
161
|
ci.published_revision_id AS pointer_revision_id,
|
|
156
162
|
ci.published_at AS canonical_published_at,
|
|
157
|
-
|
|
163
|
+
${contentDate} AS content_date,
|
|
164
|
+
length(CAST(${bodies ? 'cr.payload_json' : BODYLESS_PAYLOAD_EXPR} AS BLOB)) AS payload_bytes
|
|
158
165
|
FROM content_items ci
|
|
159
166
|
`
|
|
167
|
+
}
|
|
160
168
|
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
ci.published_at AS canonical_published_at,
|
|
166
|
-
length(CAST(${BODYLESS_PAYLOAD_EXPR} AS BLOB)) AS payload_bytes
|
|
167
|
-
FROM content_items ci
|
|
168
|
-
`
|
|
169
|
+
const SNAPSHOT_CONTENT_DATE = `CASE WHEN json_valid(cr.payload_json) = 1
|
|
170
|
+
THEN COALESCE(json_extract(cr.payload_json, '$.item.publish_at'),
|
|
171
|
+
json_extract(cr.payload_json, '$.item.published_at'), 0)
|
|
172
|
+
ELSE 0 END`
|
|
169
173
|
|
|
170
174
|
const SELECT_PUBLISHED_CONTENT_BY_SLUG = `
|
|
171
175
|
SELECT
|
|
@@ -465,6 +469,7 @@ function normalizeListOptions(options: unknown): {
|
|
|
465
469
|
type?: ContentType
|
|
466
470
|
channel?: string
|
|
467
471
|
cursor?: PublishedContentCursor
|
|
472
|
+
order: 'published' | 'content-date'
|
|
468
473
|
} {
|
|
469
474
|
if (!isPlainRecord(options)) {
|
|
470
475
|
throw new PublishedContentReadError(
|
|
@@ -506,6 +511,13 @@ function normalizeListOptions(options: unknown): {
|
|
|
506
511
|
channel = options.channel
|
|
507
512
|
}
|
|
508
513
|
let cursor: PublishedContentCursor | undefined
|
|
514
|
+
const order = options.order ?? 'published'
|
|
515
|
+
if (order !== 'published' && order !== 'content-date') {
|
|
516
|
+
throw new PublishedContentReadError(
|
|
517
|
+
'published_content_input_invalid',
|
|
518
|
+
'unknown published content order',
|
|
519
|
+
)
|
|
520
|
+
}
|
|
509
521
|
if (options.cursor !== undefined && options.cursor !== null) {
|
|
510
522
|
if (
|
|
511
523
|
!isPlainRecord(options.cursor) ||
|
|
@@ -527,9 +539,22 @@ function normalizeListOptions(options: unknown): {
|
|
|
527
539
|
)
|
|
528
540
|
}
|
|
529
541
|
assertLookupValue(options.cursor.contentId, 'cursor contentId', 256)
|
|
542
|
+
if (
|
|
543
|
+
(order === 'content-date' &&
|
|
544
|
+
(typeof options.cursor.contentDate !== 'number' ||
|
|
545
|
+
!Number.isSafeInteger(options.cursor.contentDate) ||
|
|
546
|
+
options.cursor.contentDate < 0)) ||
|
|
547
|
+
(order === 'published' && options.cursor.contentDate !== undefined)
|
|
548
|
+
) {
|
|
549
|
+
throw new PublishedContentReadError(
|
|
550
|
+
'published_content_input_invalid',
|
|
551
|
+
'cursor must match the requested content order',
|
|
552
|
+
)
|
|
553
|
+
}
|
|
530
554
|
cursor = {
|
|
531
555
|
publishedAt: options.cursor.publishedAt,
|
|
532
556
|
contentId: options.cursor.contentId,
|
|
557
|
+
...(order === 'content-date' ? { contentDate: options.cursor.contentDate as number } : {}),
|
|
533
558
|
}
|
|
534
559
|
}
|
|
535
560
|
let bodies = true
|
|
@@ -548,6 +573,7 @@ function normalizeListOptions(options: unknown): {
|
|
|
548
573
|
type,
|
|
549
574
|
channel,
|
|
550
575
|
cursor,
|
|
576
|
+
order,
|
|
551
577
|
}
|
|
552
578
|
}
|
|
553
579
|
|
|
@@ -563,6 +589,12 @@ export async function listPublishedContent(
|
|
|
563
589
|
'ci.published_at IS NOT NULL',
|
|
564
590
|
]
|
|
565
591
|
const values: unknown[] = []
|
|
592
|
+
const contentDate =
|
|
593
|
+
normalized.order === 'content-date' ? SNAPSHOT_CONTENT_DATE : 'ci.published_at'
|
|
594
|
+
const orderBy =
|
|
595
|
+
normalized.order === 'content-date'
|
|
596
|
+
? `${contentDate} DESC, ci.published_at DESC, ci.id DESC`
|
|
597
|
+
: 'ci.published_at DESC, ci.id DESC'
|
|
566
598
|
// A missing revision or type must reach the integrity checks, rather than
|
|
567
599
|
// disappearing behind a type/channel filter as a successful empty list.
|
|
568
600
|
const invalidRevisionType = `CASE WHEN json_valid(cr.payload_json) = 1
|
|
@@ -580,7 +612,15 @@ ELSE 1 END`
|
|
|
580
612
|
values.push(normalized.channel)
|
|
581
613
|
}
|
|
582
614
|
if (normalized.cursor) {
|
|
583
|
-
|
|
615
|
+
const canonicalCursor = '(ci.published_at < ? OR (ci.published_at = ? AND ci.id < ?))'
|
|
616
|
+
predicates.push(
|
|
617
|
+
normalized.order === 'content-date'
|
|
618
|
+
? `(${contentDate} < ? OR (${contentDate} = ? AND ${canonicalCursor}))`
|
|
619
|
+
: canonicalCursor,
|
|
620
|
+
)
|
|
621
|
+
if (normalized.order === 'content-date') {
|
|
622
|
+
values.push(normalized.cursor.contentDate, normalized.cursor.contentDate)
|
|
623
|
+
}
|
|
584
624
|
values.push(
|
|
585
625
|
normalized.cursor.publishedAt,
|
|
586
626
|
normalized.cursor.publishedAt,
|
|
@@ -591,10 +631,10 @@ ELSE 1 END`
|
|
|
591
631
|
const reader = openReadSession(db)
|
|
592
632
|
const outlineResult = await reader
|
|
593
633
|
.prepare(
|
|
594
|
-
`${normalized.bodies
|
|
634
|
+
`${publishedOutlineSelect(normalized.bodies, contentDate)}
|
|
595
635
|
LEFT JOIN content_revisions cr ON cr.id = ci.published_revision_id
|
|
596
636
|
WHERE ${predicates.join('\n AND ')}
|
|
597
|
-
ORDER BY
|
|
637
|
+
ORDER BY ${orderBy}
|
|
598
638
|
LIMIT ?`,
|
|
599
639
|
)
|
|
600
640
|
.bind(...values, normalized.limit + 1)
|
|
@@ -616,6 +656,17 @@ LIMIT ?`,
|
|
|
616
656
|
'published revision outline is malformed',
|
|
617
657
|
)
|
|
618
658
|
}
|
|
659
|
+
if (
|
|
660
|
+
normalized.order === 'content-date' &&
|
|
661
|
+
(typeof outline.content_date !== 'number' ||
|
|
662
|
+
!Number.isSafeInteger(outline.content_date) ||
|
|
663
|
+
outline.content_date < 0)
|
|
664
|
+
) {
|
|
665
|
+
throw new PublishedContentReadError(
|
|
666
|
+
'published_payload_invalid',
|
|
667
|
+
'published snapshot date is malformed',
|
|
668
|
+
)
|
|
669
|
+
}
|
|
619
670
|
if (outline.payload_bytes > MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES) {
|
|
620
671
|
throw new PublishedContentReadError(
|
|
621
672
|
'published_payload_limit_exceeded',
|
|
@@ -689,7 +740,15 @@ WHERE ci.status = 'published'
|
|
|
689
740
|
return {
|
|
690
741
|
items,
|
|
691
742
|
nextCursor:
|
|
692
|
-
hasMore && last
|
|
743
|
+
hasMore && last
|
|
744
|
+
? {
|
|
745
|
+
publishedAt: last.publishedAt,
|
|
746
|
+
contentId: last.contentId,
|
|
747
|
+
...(normalized.order === 'content-date'
|
|
748
|
+
? { contentDate: selected.at(-1)?.content_date }
|
|
749
|
+
: {}),
|
|
750
|
+
}
|
|
751
|
+
: null,
|
|
693
752
|
}
|
|
694
753
|
} catch (error) {
|
|
695
754
|
return wrapReadError(error)
|
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 {
|
|
@@ -6,10 +6,12 @@ import {
|
|
|
6
6
|
isMissingInsightPublicationSchema,
|
|
7
7
|
isMissingInsightsTable,
|
|
8
8
|
listInsightBoard,
|
|
9
|
+
readSelectedClusterMembership,
|
|
9
10
|
replaceInsightsBoard,
|
|
10
11
|
undismissInsight,
|
|
11
12
|
} from '../engine/content-insights.js'
|
|
12
13
|
import { verifyHmac } from '../engine/foundry-dispatch.js'
|
|
14
|
+
import { listPublishedContent, PublishedContentReadError } from '../engine/published-content.js'
|
|
13
15
|
import { InsightsIngestPayloadSchema } from '../schema/insights-ingest.js'
|
|
14
16
|
import { InsightDismissalReasonCodeSchema } from '../schema/types.js'
|
|
15
17
|
import type { CmsRouteConfig } from './config.js'
|
|
@@ -29,6 +31,103 @@ const DismissSchema = z
|
|
|
29
31
|
.strict()
|
|
30
32
|
const IntentKeySchema = z.object({ intentKey: z.string().min(1) })
|
|
31
33
|
|
|
34
|
+
const PublishedCursorSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
publishedAt: z.number().int().nonnegative(),
|
|
37
|
+
contentId: z.string().min(1).max(256),
|
|
38
|
+
})
|
|
39
|
+
.strict()
|
|
40
|
+
|
|
41
|
+
const PublishedArticleStateSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
content_id: z.string().min(1),
|
|
44
|
+
revision_id: z.string().min(1),
|
|
45
|
+
slug: z.string().min(1),
|
|
46
|
+
title: z.string().min(1),
|
|
47
|
+
canonical_url: z.string().url().nullable(),
|
|
48
|
+
published_at: z.number().int().nonnegative(),
|
|
49
|
+
source_id: z.string().nullable(),
|
|
50
|
+
youtube_video_id: z
|
|
51
|
+
.string()
|
|
52
|
+
.regex(/^[A-Za-z0-9_-]{11}$/)
|
|
53
|
+
.nullable(),
|
|
54
|
+
hero_image_id: z.string().nullable(),
|
|
55
|
+
hero_image_caption: z.string().nullable(),
|
|
56
|
+
})
|
|
57
|
+
.strict()
|
|
58
|
+
|
|
59
|
+
const PublishedInputsResponseSchema = z
|
|
60
|
+
.object({
|
|
61
|
+
ok: z.literal(true),
|
|
62
|
+
site: z.string().min(1),
|
|
63
|
+
observed_at: z.string().datetime(),
|
|
64
|
+
published: z.array(PublishedArticleStateSchema),
|
|
65
|
+
next_cursor: PublishedCursorSchema.nullable(),
|
|
66
|
+
complete: z.boolean(),
|
|
67
|
+
})
|
|
68
|
+
.strict()
|
|
69
|
+
|
|
70
|
+
// Additive read mode of the existing signed inputs route. Read the immutable
|
|
71
|
+
// published snapshot, never mutable draft fields. Reuse the CMS reader's
|
|
72
|
+
// revision checks and pagination; return only named public fields, not a raw
|
|
73
|
+
// payload that may contain internal machine trigger tokens.
|
|
74
|
+
async function publishedInputs(ctx: RouteContext, url: URL, siteId: string): Promise<Response> {
|
|
75
|
+
if (url.searchParams.get('published') !== '1')
|
|
76
|
+
return json({ error: 'invalid_published_mode' }, 400)
|
|
77
|
+
if (siteId === 'default') return json({ error: 'publication_identity_not_configured' }, 503)
|
|
78
|
+
if (url.searchParams.get('site') !== siteId) return json({ error: 'publication_mismatch' }, 403)
|
|
79
|
+
const limit = z.coerce
|
|
80
|
+
.number()
|
|
81
|
+
.int()
|
|
82
|
+
.min(1)
|
|
83
|
+
.max(100)
|
|
84
|
+
.safeParse(url.searchParams.get('limit') ?? '100')
|
|
85
|
+
if (!limit.success) return json({ error: 'invalid_limit' }, 400)
|
|
86
|
+
let cursor: z.infer<typeof PublishedCursorSchema> | null = null
|
|
87
|
+
const cursorText = url.searchParams.get('cursor')
|
|
88
|
+
if (cursorText !== null) {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = PublishedCursorSchema.safeParse(JSON.parse(cursorText))
|
|
91
|
+
if (!parsed.success) return json({ error: 'invalid_cursor' }, 400)
|
|
92
|
+
cursor = parsed.data
|
|
93
|
+
} catch {
|
|
94
|
+
return json({ error: 'invalid_cursor' }, 400)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const page = await listPublishedContent(ctx.db, {
|
|
99
|
+
type: 'article',
|
|
100
|
+
bodies: false,
|
|
101
|
+
limit: limit.data,
|
|
102
|
+
cursor,
|
|
103
|
+
})
|
|
104
|
+
const result = PublishedInputsResponseSchema.safeParse({
|
|
105
|
+
ok: true,
|
|
106
|
+
site: siteId,
|
|
107
|
+
observed_at: new Date().toISOString(),
|
|
108
|
+
published: page.items.map((content) => ({
|
|
109
|
+
content_id: content.contentId,
|
|
110
|
+
revision_id: content.revisionId,
|
|
111
|
+
slug: content.slug,
|
|
112
|
+
title: content.payload.item.title,
|
|
113
|
+
canonical_url: content.payload.item.canonical_url ?? null,
|
|
114
|
+
published_at: content.publishedAt,
|
|
115
|
+
source_id: content.payload.item.source_id ?? null,
|
|
116
|
+
youtube_video_id: content.metadata.videoId ?? null,
|
|
117
|
+
hero_image_id: content.payload.item.hero_image_id ?? null,
|
|
118
|
+
hero_image_caption: content.payload.item.hero_image_caption ?? null,
|
|
119
|
+
})),
|
|
120
|
+
next_cursor: page.nextCursor,
|
|
121
|
+
complete: page.nextCursor === null,
|
|
122
|
+
})
|
|
123
|
+
if (!result.success) return json({ error: 'published_state_invalid' }, 409)
|
|
124
|
+
return json(result.data)
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof PublishedContentReadError) return json({ error: error.code }, 409)
|
|
127
|
+
throw error
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
32
131
|
/** Resolve YYYY-MM-DD; defaults to the current UTC date when no ?runDate is given. */
|
|
33
132
|
function resolveRunDate(ctx: RouteContext): string {
|
|
34
133
|
const url = new URL(ctx.request.url)
|
|
@@ -85,6 +184,10 @@ export interface ContentInsightsRouteHandlers {
|
|
|
85
184
|
* fronts/schedule (X-Foundry-Timestamp + X-Foundry-Signature over the raw
|
|
86
185
|
* query string, verified against FOUNDRY_HMAC_SECRET) -- a sibling poll,
|
|
87
186
|
* not a new auth scheme.
|
|
187
|
+
* `published=1&site=<configured-publication>` reads a page of canonical
|
|
188
|
+
* published article revisions instead, with `limit` (1..100) and JSON
|
|
189
|
+
* `cursor` from the prior page. `complete` means the pagination ended;
|
|
190
|
+
* no total is inferred from one page. Missing URLs/video IDs are null.
|
|
88
191
|
*/
|
|
89
192
|
insightsInputs(ctx: RouteContext): Promise<Response>
|
|
90
193
|
}
|
|
@@ -210,7 +313,11 @@ export function createContentInsightsRoutes(
|
|
|
210
313
|
} catch (error) {
|
|
211
314
|
if (isMissingInsightPublicationSchema(error))
|
|
212
315
|
return json({ error: 'schema_not_ready' }, 409)
|
|
213
|
-
if (error instanceof InsightPublicationError)
|
|
316
|
+
if (error instanceof InsightPublicationError)
|
|
317
|
+
return json(
|
|
318
|
+
{ error: error.code },
|
|
319
|
+
error.code === 'selected_membership_replacement_refused' ? 422 : 409,
|
|
320
|
+
)
|
|
214
321
|
throw error
|
|
215
322
|
}
|
|
216
323
|
},
|
|
@@ -219,7 +326,13 @@ export function createContentInsightsRoutes(
|
|
|
219
326
|
// family as fronts/schedule (timestamp-bound signature over the query
|
|
220
327
|
// string; a GET has no body to bind freshness to instead).
|
|
221
328
|
async insightsInputs(ctx) {
|
|
222
|
-
|
|
329
|
+
// Channel publications use the existing shared CMS binding. An explicit
|
|
330
|
+
// legacy binding retains precedence, including loud failure if unreadable.
|
|
331
|
+
const binding =
|
|
332
|
+
ctx.env?.FOUNDRY_HMAC_SECRET === undefined
|
|
333
|
+
? 'CMS_FOUNDRY_HMAC_SECRET'
|
|
334
|
+
: 'FOUNDRY_HMAC_SECRET'
|
|
335
|
+
const hmacSecret = await resolveFoundryHmacSecretForInputs(ctx.env, binding)
|
|
223
336
|
if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
|
|
224
337
|
|
|
225
338
|
const url = new URL(ctx.request.url)
|
|
@@ -231,6 +344,8 @@ export function createContentInsightsRoutes(
|
|
|
231
344
|
)
|
|
232
345
|
if (denied) return denied
|
|
233
346
|
|
|
347
|
+
if (url.searchParams.has('published')) return publishedInputs(ctx, url, siteId)
|
|
348
|
+
|
|
234
349
|
const requestedSite = url.searchParams.get('site')
|
|
235
350
|
const authorsResult = await ctx.db.prepare(AUTHORS_QUERY).all<{
|
|
236
351
|
name: string
|
|
@@ -240,11 +355,38 @@ export function createContentInsightsRoutes(
|
|
|
240
355
|
title: string
|
|
241
356
|
slug: string
|
|
242
357
|
}>()
|
|
358
|
+
let selected: Awaited<ReturnType<typeof readSelectedClusterMembership>> | null = null
|
|
359
|
+
// Membership cannot ride the historical requested-site echo: it is
|
|
360
|
+
// returned only for this configured publication's signed request.
|
|
361
|
+
if (siteId !== 'default' && requestedSite === siteId) {
|
|
362
|
+
try {
|
|
363
|
+
selected = await readSelectedClusterMembership(ctx.db, siteId)
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (
|
|
366
|
+
!(error instanceof Error && error.message.startsWith('cluster_membership_unknown:')) &&
|
|
367
|
+
!isMissingInsightsTable(error)
|
|
368
|
+
)
|
|
369
|
+
throw error
|
|
370
|
+
}
|
|
371
|
+
}
|
|
243
372
|
return json({
|
|
244
373
|
ok: true,
|
|
245
374
|
site: requestedSite ?? siteId,
|
|
246
375
|
authors: authorsResult.results ?? [],
|
|
247
376
|
existing: existingResult.results ?? [],
|
|
377
|
+
cluster_membership: selected ? 'selected' : 'unknown',
|
|
378
|
+
cluster_selections:
|
|
379
|
+
selected?.map((membership) => ({
|
|
380
|
+
site_id: membership.site_id,
|
|
381
|
+
intent_key: membership.intent_key,
|
|
382
|
+
desk: membership.desk,
|
|
383
|
+
decision_ref: membership.decision_ref,
|
|
384
|
+
taxonomy_ref: membership.taxonomy_ref,
|
|
385
|
+
hub_content_id: membership.hub.content_id,
|
|
386
|
+
hub_revision_id: membership.hub.revision_id,
|
|
387
|
+
child_content_ids: membership.children.map((page) => page.content_id),
|
|
388
|
+
child_revision_ids: membership.children.map((page) => page.revision_id),
|
|
389
|
+
})) ?? null,
|
|
248
390
|
})
|
|
249
391
|
},
|
|
250
392
|
}
|
|
@@ -60,8 +60,9 @@ import { json, type RouteContext } from './context.js'
|
|
|
60
60
|
// family rather than inventing a second one.
|
|
61
61
|
export async function resolveFoundryHmacSecret(
|
|
62
62
|
env: Record<string, unknown> | undefined,
|
|
63
|
+
binding: 'FOUNDRY_HMAC_SECRET' | 'CMS_FOUNDRY_HMAC_SECRET' = 'FOUNDRY_HMAC_SECRET',
|
|
63
64
|
): Promise<string | null> {
|
|
64
|
-
const raw = env?.
|
|
65
|
+
const raw = env?.[binding]
|
|
65
66
|
if (typeof raw === 'string') return raw || null
|
|
66
67
|
if (raw && typeof (raw as { get?: unknown }).get === 'function') {
|
|
67
68
|
try {
|
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)
|
|
@@ -115,12 +115,42 @@ export const InsightCompetitorArticleSchema = z.object({
|
|
|
115
115
|
covered: z.boolean().optional(),
|
|
116
116
|
})
|
|
117
117
|
|
|
118
|
+
const PublishedClusterPageSchema = z
|
|
119
|
+
.object({
|
|
120
|
+
content_id: z.string().min(1),
|
|
121
|
+
revision_id: z.string().min(1),
|
|
122
|
+
url: z.string().url(),
|
|
123
|
+
title: z.string().min(1),
|
|
124
|
+
})
|
|
125
|
+
.strict()
|
|
126
|
+
|
|
127
|
+
export const InsightSelectedClusterMembershipSchema = z
|
|
128
|
+
.object({
|
|
129
|
+
site_id: z.string().min(1),
|
|
130
|
+
run_id: z.string().min(1),
|
|
131
|
+
intent_key: z.string().min(1),
|
|
132
|
+
desk: z.string().min(1),
|
|
133
|
+
decision_ref: z.string().min(1),
|
|
134
|
+
taxonomy_ref: z.string().min(1),
|
|
135
|
+
hub: PublishedClusterPageSchema,
|
|
136
|
+
children: z.array(PublishedClusterPageSchema).min(4),
|
|
137
|
+
})
|
|
138
|
+
.strict()
|
|
139
|
+
.refine((membership) => {
|
|
140
|
+
const members = [membership.hub, ...membership.children]
|
|
141
|
+
return (
|
|
142
|
+
new Set(members.map((page) => page.content_id)).size === members.length &&
|
|
143
|
+
new Set(members.map((page) => page.url)).size === members.length
|
|
144
|
+
)
|
|
145
|
+
}, 'selected hub and children must be distinct published pages')
|
|
146
|
+
|
|
118
147
|
export const InsightBriefSchema = z.object({
|
|
119
148
|
why_now: z.string().min(1),
|
|
120
149
|
angle: z.string().min(1),
|
|
121
150
|
cluster: z.object({
|
|
122
151
|
canonical: z.string().min(1),
|
|
123
152
|
spokes: z.array(z.string().min(1)),
|
|
153
|
+
selected: InsightSelectedClusterMembershipSchema.optional(),
|
|
124
154
|
}),
|
|
125
155
|
sources: z.array(
|
|
126
156
|
z.object({
|
|
@@ -200,6 +230,21 @@ export const InsightsIngestPayloadSchema = z
|
|
|
200
230
|
// replace DELETE by meta.site_id + meta.run_date — never run_date alone).
|
|
201
231
|
.superRefine((payload, ctx) => {
|
|
202
232
|
for (const [i, row] of payload.rows.entries()) {
|
|
233
|
+
const selected = row.brief.cluster.selected
|
|
234
|
+
if (
|
|
235
|
+
selected &&
|
|
236
|
+
(selected.site_id !== row.site_id ||
|
|
237
|
+
selected.run_id !== payload.meta.run_id ||
|
|
238
|
+
selected.intent_key !== row.intent_key ||
|
|
239
|
+
selected.desk !== row.desk)
|
|
240
|
+
) {
|
|
241
|
+
ctx.addIssue({
|
|
242
|
+
code: z.ZodIssueCode.custom,
|
|
243
|
+
path: ['rows', i, 'brief', 'cluster', 'selected'],
|
|
244
|
+
message:
|
|
245
|
+
'selected membership must bind the same site, run, desk and intent as its signed row',
|
|
246
|
+
})
|
|
247
|
+
}
|
|
203
248
|
if (row.site_id !== payload.meta.site_id || row.run_date !== payload.meta.run_date) {
|
|
204
249
|
ctx.addIssue({
|
|
205
250
|
code: z.ZodIssueCode.custom,
|