Skip to content

Commit 74bbc00

Browse files
byteclimberclaude
andcommitted
feat(serenity): rename the authorship dimension root from source to origin
WP-O2a: renames DIMENSION.SOURCE -> DIMENSION.ORIGIN (and SOURCE_VALUE -> ORIGIN_VALUE) in prompt-tags.js, and gives the authorship root its own tolerant resolver in tag-tree.js (resolveAuthorshipRoot): it reuses an existing `origin` root, else adopts a legacy `source` root whose children are a subset of {ai, human}, else mints a new `origin` root — never a second, empty one. ensureDimensionRoots/provisionDimensionTree/ ensureClosedValue route through this resolver instead of blind-creating the authorship root. parseCreateTagBody and the provisioning seams in markets-subworkspace.js already validate/seed against ALL_DIMENSIONS/ DIMENSION_ROOT_NAMES, so they pick up the rename automatically. WP-O2b: generalizes makeTypeInjector (handlers/prompts.js, and its subworkspace twin) to also strip+inject `origin` on every prompt write: CREATE always derives to `ai`; UPDATE re-injects whichever `origin` tag the request already carries (never re-derived), falling back to `ai` only if none is found. prompts-storage.js's upsertPrompts now derives `origin` from the caller's authenticated principal (service principal: honor an asserted ai/human value; user principal: always override to human) via the new resolveOriginForWrite; updatePromptById no longer accepts `origin` in a PATCH body; mapRowToPrompt drops the `|| 'human'` read fallback. brands.js wires isServicePrincipal from context.s2sConsumer into upsertPrompts. OpenAPI docs updated to describe the new origin semantics without changing the query filter/sort surface. Tracks WP-O2a (spacecat-api-service#2816, SITES-48001) and WP-O2b (spacecat-api-service#2817, SITES-48002) from serenity-docs PR #46. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ae51f7a commit 74bbc00

16 files changed

Lines changed: 692 additions & 202 deletions

docs/openapi/prompts-v2-api.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,10 @@ v2-prompts-by-brand-and-id:
254254
tags:
255255
- customer-config
256256
summary: Update a single prompt
257+
description: >-
258+
`origin` in the body is not honored on PATCH — it is set once at create
259+
time (see `V2PromptInput.origin`) and can never be changed afterward, so
260+
any `origin` sent here is ignored.
257261
operationId: updatePromptByBrandAndId
258262
security:
259263
- ims_key: [ ]

docs/openapi/schemas.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6643,6 +6643,11 @@ V2PromptInput:
66436643
type: string
66446644
enum: [ai, human]
66456645
default: human
6646+
description: >-
6647+
Who authored the prompt. Service-principal only: honored (and
6648+
validated against the enum) only when the caller authenticates as an
6649+
S2S service; for a user-authenticated request this field is ignored
6650+
and always overridden to `human`, regardless of what is sent.
66466651
source:
66476652
type: string
66486653
description: The source system or process that created the prompt

src/controllers/brands.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,11 @@ function BrandsController(ctx, log, env) {
554554

555555
const { postgrestClient } = context.dataAccess.services;
556556
const updatedBy = context.attributes?.authInfo?.profile?.email || 'system';
557+
// Service principals (e.g. llmo-data-retrieval-service) authenticate over
558+
// s2sAuthWrapper, which sets `context.s2sConsumer`; a user principal never
559+
// carries it. Gates whether a per-prompt `origin` in the body is honored —
560+
// see `resolveOriginForWrite`.
561+
const isServicePrincipal = Boolean(context.s2sConsumer);
557562

558563
const brandUuid = await resolveBrandUuid(spaceCatId, brandId, postgrestClient);
559564
if (!brandUuid) {
@@ -567,6 +572,7 @@ function BrandsController(ctx, log, env) {
567572
postgrestClient,
568573
updatedBy,
569574
classifyIntent: classifyIntent ?? undefined,
575+
isServicePrincipal,
570576
});
571577

572578
return createResponse({ created, updated, prompts: outPrompts }, 201);

src/support/prompts-storage.js

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,33 @@ import { hasText, isValidUUID } from '@adobe/spacecat-shared-utils';
1717
import { classifyIntents } from './intent-classifier.js';
1818
import { throwOnPgConstraintViolation } from './errors.js';
1919
import { INTENT_VALUES, normalizeIntent } from './intent.js';
20+
import { ORIGIN_VALUE } from './serenity/prompt-tags.js';
21+
22+
const ORIGIN_VALUES = new Set(Object.values(ORIGIN_VALUE));
23+
24+
/**
25+
* Resolves the `origin` a prompt write should carry, from the caller's
26+
* authenticated principal rather than trusting the request body outright.
27+
*
28+
* A SERVICE principal (e.g. llmo-data-retrieval-service, which POSTs with a
29+
* hardcoded `origin: "ai"`) may assert either enum value; an unrecognized
30+
* value is treated as absent rather than rejected — a write must never fail
31+
* over this field. A USER principal's asserted value is always ignored: a
32+
* human hitting this endpoint is definitionally authoring the prompt
33+
* themselves, so the body's `origin` (if any) is silently overridden, never
34+
* used to reject the request.
35+
*
36+
* @param {*} candidate - the request body's asserted `origin`, if any.
37+
* @param {boolean} isServicePrincipal - true when the caller authenticated as
38+
* an S2S service (see `context.s2sConsumer` at the controller boundary).
39+
* @returns {'ai' | 'human'}
40+
*/
41+
export function resolveOriginForWrite(candidate, isServicePrincipal) {
42+
if (isServicePrincipal && ORIGIN_VALUES.has(candidate)) {
43+
return candidate;
44+
}
45+
return ORIGIN_VALUE.HUMAN;
46+
}
2047

2148
// Re-exported for backward compatibility — `normalizeIntent`/`INTENT_VALUES` now
2249
// live in `./intent.js` so the LLM intent classifier can reuse them without an
@@ -405,7 +432,9 @@ function mapRowToPrompt(row) {
405432
name: row.name,
406433
regions: row.regions || [],
407434
status: row.status || 'active',
408-
origin: row.origin || 'human',
435+
// No fallback: the column has zero NULLs in production, so `|| 'human'`
436+
// was dead code that would otherwise silently mislabel a null as human.
437+
origin: row.origin,
409438
source: row.source || 'config',
410439
intent: row.intent ?? null,
411440
createdAt: row.created_at,
@@ -706,6 +735,9 @@ export async function getPromptById({
706735
* text without an explicit intent. Non-fatal: a null result leaves intent unset.
707736
* @param {number} [params.classifyIntentBatchTimeoutMs] - Cap on the classifier
708737
* batch (ms); the upsert proceeds without intent once it elapses.
738+
* @param {boolean} [params.isServicePrincipal] - true when the caller
739+
* authenticated as an S2S service; gates whether a per-prompt `origin` in
740+
* the request body is honored (see {@link resolveOriginForWrite}).
709741
* @returns {Promise<{created: number, updated: number, prompts: object[]}>}
710742
*/
711743
export async function upsertPrompts({
@@ -716,6 +748,7 @@ export async function upsertPrompts({
716748
updatedBy = 'system',
717749
classifyIntent,
718750
classifyIntentBatchTimeoutMs = 8000,
751+
isServicePrincipal = false,
719752
}) {
720753
if (!postgrestClient?.from) {
721754
throw new Error('PostgREST client is required for prompts');
@@ -789,7 +822,7 @@ export async function upsertPrompts({
789822
category_id: categoryUuid,
790823
topic_id: topicUuid,
791824
status: p.status || 'active',
792-
origin: p.origin || 'human',
825+
origin: resolveOriginForWrite(p.origin, isServicePrincipal),
793826
source: p.source || 'config',
794827
intent: normalizeIntent(p.intent),
795828
updated_by: updatedBy,
@@ -1003,9 +1036,9 @@ export async function updatePromptById({
10031036
if (updates.status !== undefined) {
10041037
patch.status = updates.status;
10051038
}
1006-
if (updates.origin !== undefined) {
1007-
patch.origin = updates.origin;
1008-
}
1039+
// `origin` is never patchable via the PATCH body — mirrors `source`, which is
1040+
// already absent here. It is set once at create time (from the authenticated
1041+
// principal — see `upsertPrompts`) and never re-derived or overridden later.
10091042
if (updates.intent !== undefined) {
10101043
// The shared fallback strips intent when the column is known-absent.
10111044
patch.intent = normalizeIntent(updates.intent);

src/support/serenity/handlers/prompts-subworkspace.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ export async function handleUpdatePromptSubworkspace(
279279
const injectComputedType = makeTypeInjector(transport, workspaceId, classifyPromptType, log);
280280
const typed = await injectComputedType(projectId, {
281281
text: nextText, geoTargetId, tagIds: nextTagIds,
282-
});
282+
}, { mode: 'update' });
283283

284284
try {
285285
await transport.deletePromptsByIds(workspaceId, projectId, [semrushPromptId]);

src/support/serenity/handlers/prompts.js

Lines changed: 93 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import { redactUpstreamMessage } from '../rest-transport.js';
1919
import { ERROR_CODES, isUpstreamGone } from '../errors.js';
2020
import { normalizeGeoTargetId, normalizeLanguageCode, isValidTagIdFormat } from '../validation.js';
2121
import { invalidateTagCacheForProject } from './markets.js';
22-
import { resolveTypeValueInjection } from '../tag-tree.js';
22+
import { resolveTypeValueInjection, resolveOriginValueInjection } from '../tag-tree.js';
23+
import { ORIGIN_VALUE } from '../prompt-tags.js';
2324

2425
// TWIN FILE: the slice→project orchestration here is paralleled by the
2526
// subworkspace-mode handlers in prompts-subworkspace.js. The duplication is
@@ -344,60 +345,115 @@ export async function createOnePrompt(transport, semrushWorkspaceId, projectId,
344345
}
345346

346347
/**
347-
* Builds the per-request `type` injector — the UNIFIED classification layer
348-
* (serenity-docs#31). Given a pure `classifyPromptType(text, geoTargetId)`
348+
* Builds the per-request `type` + `origin` injector — the UNIFIED
349+
* classification layer (serenity-docs#31, extended by the-origin-dimension
350+
* spec for `origin`). Given a pure `classifyPromptType(text, geoTargetId)`
349351
* closure (built by the controller from the brand name + region-clamped
350352
* aliases) that yields a BARE `type` value (`branded` / `non-branded`), it
351-
* returns `injectComputedType(projectId, input)` which:
352-
* - STRIPS every caller-supplied tag id that lives under the `type` root (the
353-
* client may never set the value), and
354-
* - APPENDS the pre-resolved upstream id of the server-computed value. The
355-
* atomic `createPromptsByIds` 500s on an unresolved id, so it is resolved
353+
* returns `injectComputedType(projectId, input, options)` which, for BOTH
354+
* dimensions:
355+
* - STRIPS every caller-supplied tag id that lives under the dimension's root
356+
* (the client may never set either value directly), and
357+
* - APPENDS a resolved upstream tag id in its place. The atomic
358+
* `createPromptsByIds` 500s on an unresolved id, so ids are resolved
356359
* BEFORE the write.
357360
* The returned input carries the rewritten `tagIds`, so the caller's response
358-
* echo reflects the computed type without a refetch (decision 5).
361+
* echo reflects the computed values without a refetch (decision 5).
359362
*
360-
* The strip set is every id under the `type` root, not a name prefix: a tag's
361-
* dimension is its root, and a sub-category could legitimately be named
362-
* `branded` without being a `type` value.
363+
* The strip set for each dimension is every id under that dimension's root,
364+
* not a name prefix: a tag's dimension is its root, and a sub-category could
365+
* legitimately be named `branded` or `ai` without being a `type`/`origin` value.
363366
*
364-
* Resolution ({@link resolveTypeValueInjection}, two tag-tree reads per distinct
365-
* `type` value per project — the root level plus the `type` root's children) is
366-
* memoized for the request, so a bulk create fans out over the distinct computed
367-
* values rather than over the items. A non-function `classifyPromptType`
368-
* (defensive) is a pass-through.
367+
* `type` is always freshly classified from the write's own text. `origin` is
368+
* asymmetric between create and update (`options.mode`, default `'create'`):
369+
* - `'create'`: derives to {@link ORIGIN_VALUE.AI} — every prompt this layer
370+
* creates is AI-generated.
371+
* - `'update'`: re-injects whichever `origin` tag id is ALREADY in
372+
* `input.tagIds` (the client round-trips the tags it read off the prior
373+
* list call), rather than deriving a new one from anything in the update
374+
* request. This is deliberate: `origin` records who authored the prompt
375+
* ORIGINALLY, which an edit does not change. If none is found (a prompt
376+
* tagged before this dimension existed), it falls back to `AI` rather than
377+
* leave the prompt untagged — a strip-without-inject would make it invisible
378+
* to origin-based filtering.
369379
*
370-
* `resolveTypeValueInjection` resolves or throws, so the computed tag is always
371-
* attached. It must never be dropped: `type` is the one dimension a client may
372-
* not set, so a prompt written without it stays unclassified forever, and the
373-
* caller sees a 2xx. Failing the write instead is free — the upstream bulk create
374-
* is atomic and has not run yet.
380+
* Resolution (two tag-tree reads per distinct value per project — the root
381+
* level plus that dimension root's children) is memoized for the request per
382+
* dimension, so a bulk create fans out over the distinct computed values
383+
* rather than over the items. A non-function `classifyPromptType` (defensive)
384+
* skips the `type` step; `origin` is always injected.
385+
*
386+
* Both resolvers resolve or throw, so the computed tags are always attached.
387+
* They must never be dropped: neither dimension is client-settable, so a
388+
* prompt written without one stays unclassified forever, and the caller sees a
389+
* 2xx. Failing the write instead is free — the upstream bulk create is atomic
390+
* and has not run yet.
375391
*
376392
* @param {object} transport - Serenity transport (Semrush proxy client).
377393
* @param {string} semrushWorkspaceId
378394
* @param {((text: string, geoTargetId: number) => string) | undefined} classifyPromptType
379395
* @param {object} [log]
380396
* @returns {(projectId: string, input: { text: string, geoTargetId: number,
381-
* tagIds: string[] }) =>
397+
* tagIds: string[] }, options?: { mode?: 'create' | 'update' }) =>
382398
* Promise<{ text: string, geoTargetId: number, tagIds: string[] }>}
383399
*/
384400
export function makeTypeInjector(transport, semrushWorkspaceId, classifyPromptType, log) {
385401
/** @type {Map<string, Promise<{ computedId: string, typeTagIds: string[] }>>} */
386-
const cache = new Map();
387-
return async function injectComputedType(projectId, input) {
388-
if (typeof classifyPromptType !== 'function') {
389-
return input;
402+
const typeCache = new Map();
403+
/** @type {Map<string, Promise<{ computedId: string, originTagIds: string[] }>>} */
404+
const originCache = new Map();
405+
406+
return async function injectComputedType(projectId, input, { mode = 'create' } = {}) {
407+
let out = input;
408+
409+
if (typeof classifyPromptType === 'function') {
410+
const typeValue = classifyPromptType(input.text, input.geoTargetId);
411+
const typeKey = `${projectId} ${typeValue}`;
412+
let typePending = typeCache.get(typeKey);
413+
if (!typePending) {
414+
typePending = resolveTypeValueInjection(
415+
transport,
416+
semrushWorkspaceId,
417+
projectId,
418+
typeValue,
419+
log,
420+
);
421+
typeCache.set(typeKey, typePending);
422+
}
423+
const { computedId, typeTagIds } = await typePending;
424+
const stripped = out.tagIds.filter((id) => !typeTagIds.includes(id));
425+
out = { ...out, tagIds: [...stripped, computedId] };
390426
}
391-
const typeValue = classifyPromptType(input.text, input.geoTargetId);
392-
const key = `${projectId} ${typeValue}`;
393-
let pending = cache.get(key);
394-
if (!pending) {
395-
pending = resolveTypeValueInjection(transport, semrushWorkspaceId, projectId, typeValue, log);
396-
cache.set(key, pending);
427+
428+
// `origin` resolution is the same tree lookup on both create and update —
429+
// every id under the root, plus the resolved `AI` id — only what CREATE
430+
// does with it differs from what UPDATE does.
431+
const originKey = `${projectId} ${ORIGIN_VALUE.AI}`;
432+
let originPending = originCache.get(originKey);
433+
if (!originPending) {
434+
originPending = resolveOriginValueInjection(
435+
transport,
436+
semrushWorkspaceId,
437+
projectId,
438+
ORIGIN_VALUE.AI,
439+
log,
440+
);
441+
originCache.set(originKey, originPending);
397442
}
398-
const { computedId, typeTagIds } = await pending;
399-
const stripped = input.tagIds.filter((id) => !typeTagIds.includes(id));
400-
return { ...input, tagIds: [...stripped, computedId] };
443+
const { computedId: aiId, originTagIds } = await originPending;
444+
const stripped = out.tagIds.filter((id) => !originTagIds.includes(id));
445+
446+
// UPDATE re-injects whichever origin tag the caller already carried — it
447+
// never derives a new one. CREATE always derives to AI. Falling back to AI
448+
// when update finds none avoids a strip-without-inject (which would make
449+
// the prompt invisible to origin-based filtering) for a prompt tagged
450+
// before this dimension existed.
451+
const nextOriginId = mode === 'update'
452+
? (out.tagIds.find((id) => originTagIds.includes(id)) ?? aiId)
453+
: aiId;
454+
out = { ...out, tagIds: [...stripped, nextOriginId] };
455+
456+
return out;
401457
};
402458
}
403459

@@ -690,7 +746,7 @@ export async function handleUpdatePrompt(
690746
);
691747
const typed = await injectComputedType(projectId, {
692748
text: nextText, geoTargetId, tagIds: nextTagIds,
693-
});
749+
}, { mode: 'update' });
694750

695751
try {
696752
await transport.deletePromptsByIds(semrushWorkspaceId, projectId, [semrushPromptId]);

src/support/serenity/prompt-tags.js

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
* serenity flow.
2020
*
2121
* A tag's DIMENSION is its root ancestor, not a prefix on its name. Every
22-
* project's tag tree has exactly four roots — `category`, `intent`, `source`,
22+
* project's tag tree has exactly four roots — `category`, `intent`, `origin`,
2323
* `type` — and every tag value is a bare-named descendant of one of them. No
2424
* tag name contains a `:`. A tag's dimension is therefore `path[0]` of the
2525
* upstream breadcrumb (verified against the live Semrush API: `path[]` is a
@@ -30,8 +30,15 @@
3030
* The upstream API caps neither, so nothing here does either.
3131
*
3232
* Names are NOT unique on their own — upstream uniqueness is scoped per
33-
* `(project, parent)`. A sub-category named `human` and the `source` value
33+
* `(project, parent)`. A sub-category named `human` and the `origin` value
3434
* `human` are two distinct tags. Never key a tag by name alone; key by id.
35+
*
36+
* `origin` used to be named `source`. Some live projects still carry the
37+
* authorship root under that old name — `tag-tree.js`'s
38+
* `resolveAuthorshipRoot` is the tolerant resolver that reuses a project's
39+
* existing `source` root instead of minting a second, empty `origin` root; see
40+
* that module for the full rationale. This module only names the CURRENT
41+
* (`origin`) vocabulary; it has no notion of the legacy name.
3542
*/
3643

3744
/**
@@ -40,20 +47,20 @@
4047
export const DIMENSION = Object.freeze({
4148
CATEGORY: 'category',
4249
INTENT: 'intent',
43-
SOURCE: 'source',
50+
ORIGIN: 'origin',
4451
TYPE: 'type',
4552
});
4653

4754
/** Root names, in the order they are provisioned on a project. */
4855
export const DIMENSION_ROOT_NAMES = Object.freeze([
4956
DIMENSION.CATEGORY,
5057
DIMENSION.INTENT,
51-
DIMENSION.SOURCE,
58+
DIMENSION.ORIGIN,
5259
DIMENSION.TYPE,
5360
]);
5461

55-
/** `source` values — who authored the prompt. */
56-
export const SOURCE_VALUE = Object.freeze({
62+
/** `origin` values — who authored the prompt. */
63+
export const ORIGIN_VALUE = Object.freeze({
5764
AI: 'ai',
5865
HUMAN: 'human',
5966
});
@@ -98,14 +105,14 @@ export const TYPE_VALUE = Object.freeze({
98105
*/
99106
export const CLOSED_DIMENSION_VALUES = Object.freeze({
100107
[DIMENSION.INTENT]: Object.freeze(Object.values(INTENT_VALUE)),
101-
[DIMENSION.SOURCE]: Object.freeze(Object.values(SOURCE_VALUE)),
108+
[DIMENSION.ORIGIN]: Object.freeze(Object.values(ORIGIN_VALUE)),
102109
[DIMENSION.TYPE]: Object.freeze(Object.values(TYPE_VALUE)),
103110
});
104111

105112
/** The closed dimensions — fixed vocabularies, never customer-authored. */
106113
export const CLOSED_DIMENSIONS = Object.freeze([
107114
DIMENSION.INTENT,
108-
DIMENSION.SOURCE,
115+
DIMENSION.ORIGIN,
109116
DIMENSION.TYPE,
110117
]);
111118

@@ -120,7 +127,7 @@ export const OPEN_DIMENSIONS = Object.freeze([DIMENSION.CATEGORY]);
120127
export const ALL_DIMENSIONS = Object.freeze([...OPEN_DIMENSIONS, ...CLOSED_DIMENSIONS]);
121128

122129
/**
123-
* The closed-dimension values applied to EVERY AI-generated prompt: `source:ai`
130+
* The closed-dimension values applied to EVERY AI-generated prompt: `origin:ai`
124131
* (AI-authored) plus the default `Informational` intent (the most common intent
125132
* for brand-topic prompts; re-classification can refine it later). The `type`
126133
* value is classified per prompt at generation time (branded vs non-branded —
@@ -130,7 +137,7 @@ export const ALL_DIMENSIONS = Object.freeze([...OPEN_DIMENSIONS, ...CLOSED_DIMEN
130137
* the pair to an upstream tag id against the project's tree.
131138
*/
132139
export const STANDARD_PROMPT_TAG_VALUES = Object.freeze([
133-
Object.freeze({ dimension: DIMENSION.SOURCE, name: SOURCE_VALUE.AI }),
140+
Object.freeze({ dimension: DIMENSION.ORIGIN, name: ORIGIN_VALUE.AI }),
134141
Object.freeze({ dimension: DIMENSION.INTENT, name: INTENT_VALUE.INFORMATIONAL }),
135142
]);
136143

0 commit comments

Comments
 (0)