From 74bbc009fba93fca3f4b425e782cd67ea3776a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Toccane?= Date: Tue, 14 Jul 2026 16:30:58 +0200 Subject: [PATCH] feat(serenity): rename the authorship dimension root from source to origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/openapi/prompts-v2-api.yaml | 4 + docs/openapi/schemas.yaml | 5 + src/controllers/brands.js | 6 + src/support/prompts-storage.js | 43 +++- .../serenity/handlers/prompts-subworkspace.js | 2 +- src/support/serenity/handlers/prompts.js | 130 ++++++++---- src/support/serenity/prompt-tags.js | 27 ++- src/support/serenity/tag-tree.js | 186 ++++++++++++++++-- test/support/prompts-storage.test.js | 79 +++++++- test/support/serenity/fixtures/tag-tree.js | 16 +- .../handlers/markets-subworkspace.test.js | 8 +- .../handlers/prompts-subworkspace.test.js | 18 +- .../support/serenity/handlers/prompts.test.js | 159 ++++++++++----- test/support/serenity/handlers/tags.test.js | 73 +++---- test/support/serenity/prompt-tags.test.js | 16 +- test/support/serenity/tag-tree.test.js | 122 +++++++++--- 16 files changed, 692 insertions(+), 202 deletions(-) diff --git a/docs/openapi/prompts-v2-api.yaml b/docs/openapi/prompts-v2-api.yaml index 3b6de631f4..b4b547c493 100644 --- a/docs/openapi/prompts-v2-api.yaml +++ b/docs/openapi/prompts-v2-api.yaml @@ -254,6 +254,10 @@ v2-prompts-by-brand-and-id: tags: - customer-config summary: Update a single prompt + description: >- + `origin` in the body is not honored on PATCH — it is set once at create + time (see `V2PromptInput.origin`) and can never be changed afterward, so + any `origin` sent here is ignored. operationId: updatePromptByBrandAndId security: - ims_key: [ ] diff --git a/docs/openapi/schemas.yaml b/docs/openapi/schemas.yaml index 6084910988..9bba5fca5b 100644 --- a/docs/openapi/schemas.yaml +++ b/docs/openapi/schemas.yaml @@ -6643,6 +6643,11 @@ V2PromptInput: type: string enum: [ai, human] default: human + description: >- + Who authored the prompt. Service-principal only: honored (and + validated against the enum) only when the caller authenticates as an + S2S service; for a user-authenticated request this field is ignored + and always overridden to `human`, regardless of what is sent. source: type: string description: The source system or process that created the prompt diff --git a/src/controllers/brands.js b/src/controllers/brands.js index cb7a5b6ab6..f6ae443b6e 100644 --- a/src/controllers/brands.js +++ b/src/controllers/brands.js @@ -554,6 +554,11 @@ function BrandsController(ctx, log, env) { const { postgrestClient } = context.dataAccess.services; const updatedBy = context.attributes?.authInfo?.profile?.email || 'system'; + // Service principals (e.g. llmo-data-retrieval-service) authenticate over + // s2sAuthWrapper, which sets `context.s2sConsumer`; a user principal never + // carries it. Gates whether a per-prompt `origin` in the body is honored — + // see `resolveOriginForWrite`. + const isServicePrincipal = Boolean(context.s2sConsumer); const brandUuid = await resolveBrandUuid(spaceCatId, brandId, postgrestClient); if (!brandUuid) { @@ -567,6 +572,7 @@ function BrandsController(ctx, log, env) { postgrestClient, updatedBy, classifyIntent: classifyIntent ?? undefined, + isServicePrincipal, }); return createResponse({ created, updated, prompts: outPrompts }, 201); diff --git a/src/support/prompts-storage.js b/src/support/prompts-storage.js index 811b0561b6..0ab5dfc345 100644 --- a/src/support/prompts-storage.js +++ b/src/support/prompts-storage.js @@ -17,6 +17,33 @@ import { hasText, isValidUUID } from '@adobe/spacecat-shared-utils'; import { classifyIntents } from './intent-classifier.js'; import { throwOnPgConstraintViolation } from './errors.js'; import { INTENT_VALUES, normalizeIntent } from './intent.js'; +import { ORIGIN_VALUE } from './serenity/prompt-tags.js'; + +const ORIGIN_VALUES = new Set(Object.values(ORIGIN_VALUE)); + +/** + * Resolves the `origin` a prompt write should carry, from the caller's + * authenticated principal rather than trusting the request body outright. + * + * A SERVICE principal (e.g. llmo-data-retrieval-service, which POSTs with a + * hardcoded `origin: "ai"`) may assert either enum value; an unrecognized + * value is treated as absent rather than rejected — a write must never fail + * over this field. A USER principal's asserted value is always ignored: a + * human hitting this endpoint is definitionally authoring the prompt + * themselves, so the body's `origin` (if any) is silently overridden, never + * used to reject the request. + * + * @param {*} candidate - the request body's asserted `origin`, if any. + * @param {boolean} isServicePrincipal - true when the caller authenticated as + * an S2S service (see `context.s2sConsumer` at the controller boundary). + * @returns {'ai' | 'human'} + */ +export function resolveOriginForWrite(candidate, isServicePrincipal) { + if (isServicePrincipal && ORIGIN_VALUES.has(candidate)) { + return candidate; + } + return ORIGIN_VALUE.HUMAN; +} // Re-exported for backward compatibility — `normalizeIntent`/`INTENT_VALUES` now // live in `./intent.js` so the LLM intent classifier can reuse them without an @@ -405,7 +432,9 @@ function mapRowToPrompt(row) { name: row.name, regions: row.regions || [], status: row.status || 'active', - origin: row.origin || 'human', + // No fallback: the column has zero NULLs in production, so `|| 'human'` + // was dead code that would otherwise silently mislabel a null as human. + origin: row.origin, source: row.source || 'config', intent: row.intent ?? null, createdAt: row.created_at, @@ -706,6 +735,9 @@ export async function getPromptById({ * text without an explicit intent. Non-fatal: a null result leaves intent unset. * @param {number} [params.classifyIntentBatchTimeoutMs] - Cap on the classifier * batch (ms); the upsert proceeds without intent once it elapses. + * @param {boolean} [params.isServicePrincipal] - true when the caller + * authenticated as an S2S service; gates whether a per-prompt `origin` in + * the request body is honored (see {@link resolveOriginForWrite}). * @returns {Promise<{created: number, updated: number, prompts: object[]}>} */ export async function upsertPrompts({ @@ -716,6 +748,7 @@ export async function upsertPrompts({ updatedBy = 'system', classifyIntent, classifyIntentBatchTimeoutMs = 8000, + isServicePrincipal = false, }) { if (!postgrestClient?.from) { throw new Error('PostgREST client is required for prompts'); @@ -789,7 +822,7 @@ export async function upsertPrompts({ category_id: categoryUuid, topic_id: topicUuid, status: p.status || 'active', - origin: p.origin || 'human', + origin: resolveOriginForWrite(p.origin, isServicePrincipal), source: p.source || 'config', intent: normalizeIntent(p.intent), updated_by: updatedBy, @@ -1003,9 +1036,9 @@ export async function updatePromptById({ if (updates.status !== undefined) { patch.status = updates.status; } - if (updates.origin !== undefined) { - patch.origin = updates.origin; - } + // `origin` is never patchable via the PATCH body — mirrors `source`, which is + // already absent here. It is set once at create time (from the authenticated + // principal — see `upsertPrompts`) and never re-derived or overridden later. if (updates.intent !== undefined) { // The shared fallback strips intent when the column is known-absent. patch.intent = normalizeIntent(updates.intent); diff --git a/src/support/serenity/handlers/prompts-subworkspace.js b/src/support/serenity/handlers/prompts-subworkspace.js index 16a0feccf7..fbd8883717 100644 --- a/src/support/serenity/handlers/prompts-subworkspace.js +++ b/src/support/serenity/handlers/prompts-subworkspace.js @@ -279,7 +279,7 @@ export async function handleUpdatePromptSubworkspace( const injectComputedType = makeTypeInjector(transport, workspaceId, classifyPromptType, log); const typed = await injectComputedType(projectId, { text: nextText, geoTargetId, tagIds: nextTagIds, - }); + }, { mode: 'update' }); try { await transport.deletePromptsByIds(workspaceId, projectId, [semrushPromptId]); diff --git a/src/support/serenity/handlers/prompts.js b/src/support/serenity/handlers/prompts.js index 26b1fd3af9..9a3191dc2a 100644 --- a/src/support/serenity/handlers/prompts.js +++ b/src/support/serenity/handlers/prompts.js @@ -19,7 +19,8 @@ import { redactUpstreamMessage } from '../rest-transport.js'; import { ERROR_CODES, isUpstreamGone } from '../errors.js'; import { normalizeGeoTargetId, normalizeLanguageCode, isValidTagIdFormat } from '../validation.js'; import { invalidateTagCacheForProject } from './markets.js'; -import { resolveTypeValueInjection } from '../tag-tree.js'; +import { resolveTypeValueInjection, resolveOriginValueInjection } from '../tag-tree.js'; +import { ORIGIN_VALUE } from '../prompt-tags.js'; // TWIN FILE: the slice→project orchestration here is paralleled by the // subworkspace-mode handlers in prompts-subworkspace.js. The duplication is @@ -344,60 +345,115 @@ export async function createOnePrompt(transport, semrushWorkspaceId, projectId, } /** - * Builds the per-request `type` injector — the UNIFIED classification layer - * (serenity-docs#31). Given a pure `classifyPromptType(text, geoTargetId)` + * Builds the per-request `type` + `origin` injector — the UNIFIED + * classification layer (serenity-docs#31, extended by the-origin-dimension + * spec for `origin`). Given a pure `classifyPromptType(text, geoTargetId)` * closure (built by the controller from the brand name + region-clamped * aliases) that yields a BARE `type` value (`branded` / `non-branded`), it - * returns `injectComputedType(projectId, input)` which: - * - STRIPS every caller-supplied tag id that lives under the `type` root (the - * client may never set the value), and - * - APPENDS the pre-resolved upstream id of the server-computed value. The - * atomic `createPromptsByIds` 500s on an unresolved id, so it is resolved + * returns `injectComputedType(projectId, input, options)` which, for BOTH + * dimensions: + * - STRIPS every caller-supplied tag id that lives under the dimension's root + * (the client may never set either value directly), and + * - APPENDS a resolved upstream tag id in its place. The atomic + * `createPromptsByIds` 500s on an unresolved id, so ids are resolved * BEFORE the write. * The returned input carries the rewritten `tagIds`, so the caller's response - * echo reflects the computed type without a refetch (decision 5). + * echo reflects the computed values without a refetch (decision 5). * - * The strip set is every id under the `type` root, not a name prefix: a tag's - * dimension is its root, and a sub-category could legitimately be named - * `branded` without being a `type` value. + * The strip set for each dimension is every id under that dimension's root, + * not a name prefix: a tag's dimension is its root, and a sub-category could + * legitimately be named `branded` or `ai` without being a `type`/`origin` value. * - * Resolution ({@link resolveTypeValueInjection}, two tag-tree reads per distinct - * `type` value per project — the root level plus the `type` root's children) is - * memoized for the request, so a bulk create fans out over the distinct computed - * values rather than over the items. A non-function `classifyPromptType` - * (defensive) is a pass-through. + * `type` is always freshly classified from the write's own text. `origin` is + * asymmetric between create and update (`options.mode`, default `'create'`): + * - `'create'`: derives to {@link ORIGIN_VALUE.AI} — every prompt this layer + * creates is AI-generated. + * - `'update'`: re-injects whichever `origin` tag id is ALREADY in + * `input.tagIds` (the client round-trips the tags it read off the prior + * list call), rather than deriving a new one from anything in the update + * request. This is deliberate: `origin` records who authored the prompt + * ORIGINALLY, which an edit does not change. If none is found (a prompt + * tagged before this dimension existed), it falls back to `AI` rather than + * leave the prompt untagged — a strip-without-inject would make it invisible + * to origin-based filtering. * - * `resolveTypeValueInjection` resolves or throws, so the computed tag is always - * attached. It must never be dropped: `type` is the one dimension a client may - * not set, so a prompt written without it stays unclassified forever, and the - * caller sees a 2xx. Failing the write instead is free — the upstream bulk create - * is atomic and has not run yet. + * Resolution (two tag-tree reads per distinct value per project — the root + * level plus that dimension root's children) is memoized for the request per + * dimension, so a bulk create fans out over the distinct computed values + * rather than over the items. A non-function `classifyPromptType` (defensive) + * skips the `type` step; `origin` is always injected. + * + * Both resolvers resolve or throw, so the computed tags are always attached. + * They must never be dropped: neither dimension is client-settable, so a + * prompt written without one stays unclassified forever, and the caller sees a + * 2xx. Failing the write instead is free — the upstream bulk create is atomic + * and has not run yet. * * @param {object} transport - Serenity transport (Semrush proxy client). * @param {string} semrushWorkspaceId * @param {((text: string, geoTargetId: number) => string) | undefined} classifyPromptType * @param {object} [log] * @returns {(projectId: string, input: { text: string, geoTargetId: number, - * tagIds: string[] }) => + * tagIds: string[] }, options?: { mode?: 'create' | 'update' }) => * Promise<{ text: string, geoTargetId: number, tagIds: string[] }>} */ export function makeTypeInjector(transport, semrushWorkspaceId, classifyPromptType, log) { /** @type {Map>} */ - const cache = new Map(); - return async function injectComputedType(projectId, input) { - if (typeof classifyPromptType !== 'function') { - return input; + const typeCache = new Map(); + /** @type {Map>} */ + const originCache = new Map(); + + return async function injectComputedType(projectId, input, { mode = 'create' } = {}) { + let out = input; + + if (typeof classifyPromptType === 'function') { + const typeValue = classifyPromptType(input.text, input.geoTargetId); + const typeKey = `${projectId} ${typeValue}`; + let typePending = typeCache.get(typeKey); + if (!typePending) { + typePending = resolveTypeValueInjection( + transport, + semrushWorkspaceId, + projectId, + typeValue, + log, + ); + typeCache.set(typeKey, typePending); + } + const { computedId, typeTagIds } = await typePending; + const stripped = out.tagIds.filter((id) => !typeTagIds.includes(id)); + out = { ...out, tagIds: [...stripped, computedId] }; } - const typeValue = classifyPromptType(input.text, input.geoTargetId); - const key = `${projectId} ${typeValue}`; - let pending = cache.get(key); - if (!pending) { - pending = resolveTypeValueInjection(transport, semrushWorkspaceId, projectId, typeValue, log); - cache.set(key, pending); + + // `origin` resolution is the same tree lookup on both create and update — + // every id under the root, plus the resolved `AI` id — only what CREATE + // does with it differs from what UPDATE does. + const originKey = `${projectId} ${ORIGIN_VALUE.AI}`; + let originPending = originCache.get(originKey); + if (!originPending) { + originPending = resolveOriginValueInjection( + transport, + semrushWorkspaceId, + projectId, + ORIGIN_VALUE.AI, + log, + ); + originCache.set(originKey, originPending); } - const { computedId, typeTagIds } = await pending; - const stripped = input.tagIds.filter((id) => !typeTagIds.includes(id)); - return { ...input, tagIds: [...stripped, computedId] }; + const { computedId: aiId, originTagIds } = await originPending; + const stripped = out.tagIds.filter((id) => !originTagIds.includes(id)); + + // UPDATE re-injects whichever origin tag the caller already carried — it + // never derives a new one. CREATE always derives to AI. Falling back to AI + // when update finds none avoids a strip-without-inject (which would make + // the prompt invisible to origin-based filtering) for a prompt tagged + // before this dimension existed. + const nextOriginId = mode === 'update' + ? (out.tagIds.find((id) => originTagIds.includes(id)) ?? aiId) + : aiId; + out = { ...out, tagIds: [...stripped, nextOriginId] }; + + return out; }; } @@ -690,7 +746,7 @@ export async function handleUpdatePrompt( ); const typed = await injectComputedType(projectId, { text: nextText, geoTargetId, tagIds: nextTagIds, - }); + }, { mode: 'update' }); try { await transport.deletePromptsByIds(semrushWorkspaceId, projectId, [semrushPromptId]); diff --git a/src/support/serenity/prompt-tags.js b/src/support/serenity/prompt-tags.js index 5ce2138d2b..cb02f7ea45 100644 --- a/src/support/serenity/prompt-tags.js +++ b/src/support/serenity/prompt-tags.js @@ -19,7 +19,7 @@ * serenity flow. * * A tag's DIMENSION is its root ancestor, not a prefix on its name. Every - * project's tag tree has exactly four roots — `category`, `intent`, `source`, + * project's tag tree has exactly four roots — `category`, `intent`, `origin`, * `type` — and every tag value is a bare-named descendant of one of them. No * tag name contains a `:`. A tag's dimension is therefore `path[0]` of the * upstream breadcrumb (verified against the live Semrush API: `path[]` is a @@ -30,8 +30,15 @@ * The upstream API caps neither, so nothing here does either. * * Names are NOT unique on their own — upstream uniqueness is scoped per - * `(project, parent)`. A sub-category named `human` and the `source` value + * `(project, parent)`. A sub-category named `human` and the `origin` value * `human` are two distinct tags. Never key a tag by name alone; key by id. + * + * `origin` used to be named `source`. Some live projects still carry the + * authorship root under that old name — `tag-tree.js`'s + * `resolveAuthorshipRoot` is the tolerant resolver that reuses a project's + * existing `source` root instead of minting a second, empty `origin` root; see + * that module for the full rationale. This module only names the CURRENT + * (`origin`) vocabulary; it has no notion of the legacy name. */ /** @@ -40,7 +47,7 @@ export const DIMENSION = Object.freeze({ CATEGORY: 'category', INTENT: 'intent', - SOURCE: 'source', + ORIGIN: 'origin', TYPE: 'type', }); @@ -48,12 +55,12 @@ export const DIMENSION = Object.freeze({ export const DIMENSION_ROOT_NAMES = Object.freeze([ DIMENSION.CATEGORY, DIMENSION.INTENT, - DIMENSION.SOURCE, + DIMENSION.ORIGIN, DIMENSION.TYPE, ]); -/** `source` values — who authored the prompt. */ -export const SOURCE_VALUE = Object.freeze({ +/** `origin` values — who authored the prompt. */ +export const ORIGIN_VALUE = Object.freeze({ AI: 'ai', HUMAN: 'human', }); @@ -98,14 +105,14 @@ export const TYPE_VALUE = Object.freeze({ */ export const CLOSED_DIMENSION_VALUES = Object.freeze({ [DIMENSION.INTENT]: Object.freeze(Object.values(INTENT_VALUE)), - [DIMENSION.SOURCE]: Object.freeze(Object.values(SOURCE_VALUE)), + [DIMENSION.ORIGIN]: Object.freeze(Object.values(ORIGIN_VALUE)), [DIMENSION.TYPE]: Object.freeze(Object.values(TYPE_VALUE)), }); /** The closed dimensions — fixed vocabularies, never customer-authored. */ export const CLOSED_DIMENSIONS = Object.freeze([ DIMENSION.INTENT, - DIMENSION.SOURCE, + DIMENSION.ORIGIN, DIMENSION.TYPE, ]); @@ -120,7 +127,7 @@ export const OPEN_DIMENSIONS = Object.freeze([DIMENSION.CATEGORY]); export const ALL_DIMENSIONS = Object.freeze([...OPEN_DIMENSIONS, ...CLOSED_DIMENSIONS]); /** - * The closed-dimension values applied to EVERY AI-generated prompt: `source:ai` + * The closed-dimension values applied to EVERY AI-generated prompt: `origin:ai` * (AI-authored) plus the default `Informational` intent (the most common intent * for brand-topic prompts; re-classification can refine it later). The `type` * 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 * the pair to an upstream tag id against the project's tree. */ export const STANDARD_PROMPT_TAG_VALUES = Object.freeze([ - Object.freeze({ dimension: DIMENSION.SOURCE, name: SOURCE_VALUE.AI }), + Object.freeze({ dimension: DIMENSION.ORIGIN, name: ORIGIN_VALUE.AI }), Object.freeze({ dimension: DIMENSION.INTENT, name: INTENT_VALUE.INFORMATIONAL }), ]); diff --git a/src/support/serenity/tag-tree.js b/src/support/serenity/tag-tree.js index 825d41db88..e7c312c1c2 100644 --- a/src/support/serenity/tag-tree.js +++ b/src/support/serenity/tag-tree.js @@ -26,7 +26,7 @@ * Every create in this module is therefore resolve-before-create. Names are * unique per `(project, parent)`, not per project, so the resolve must be * scoped to the parent — a bare-name lookup across the whole tree would - * conflate a sub-category `human` with the `source` value `human`. + * conflate a sub-category `human` with the `origin` value `human`. * - Tag writes land in the project's DRAFT layer, and a default read serves the * LIVE view. Reads here go through {@link listProjectTagTree}, which passes * `draft: true`, so a tag this module just created is visible to the tag @@ -49,10 +49,29 @@ import { listProjectTagTree } from './handlers/markets.js'; import { DIMENSION, DIMENSION_ROOT_NAMES, + ORIGIN_VALUE, CLOSED_DIMENSION_VALUES, CLOSED_DIMENSIONS, } from './prompt-tags.js'; +/** + * The legacy authorship root's name, pre-rename. {@link resolveAuthorshipRoot} + * is the only place this literal may appear — everywhere else in the serenity + * flow addresses the root by its current name, {@link DIMENSION.ORIGIN}. + */ +const LEGACY_AUTHORSHIP_ROOT_NAME = 'source'; + +/** The only children a legacy `source` root may carry to be adopted as `origin`. */ +const LEGACY_AUTHORSHIP_ALLOWED_CHILDREN = new Set( + /** @type {readonly string[]} */ (Object.values(ORIGIN_VALUE)), +); + +/** The three dimension roots resolved by blind resolve-or-create. */ +const BLIND_ROOT_NAMES = Object.freeze( + (/** @type {readonly string[]} */ (DIMENSION_ROOT_NAMES)) + .filter((name) => name !== DIMENSION.ORIGIN), +); + /** * Where one tag sits in the dimension tree. * @@ -211,25 +230,90 @@ export async function ensureChildren( } /** - * Resolves the four dimension roots, creating any that a project is missing. - * Older projects predate this taxonomy entirely, so this is the seam that brings - * them forward on first touch. + * Resolves the authorship root: `origin` if the project already has one; else + * the project's legacy `source` root, IF its existing children are a subset of + * the authorship vocabulary (`ai` / `human`); else a newly-minted `origin` root. + * + * This is the tolerant-resolver seam for the `source` → `origin` rename. Every + * reader of the tag tree — provisioning, ordinary tag-create, closed-value + * resolution — MUST go through this instead of blindly resolving/creating + * `origin`, or a routine write on a project still carrying `source` would mint + * a second, empty `origin` root alongside the live one. + * + * The children-subset check is what keeps this from adopting an unrelated + * future dimension that happens to be named `source` with a different child + * vocabulary — it only ever adopts a `source` root that looks like the + * authorship root that predates this rename. * * @param {object} transport - Serenity transport (Semrush proxy client). * @param {string} semrushWorkspaceId * @param {string} projectId * @param {object} [log] - logger. - * @returns {Promise>} root name → tag id, for all four roots. + * @returns {Promise} the authorship root's tag id. */ -export async function ensureDimensionRoots(transport, semrushWorkspaceId, projectId, log) { +export async function resolveAuthorshipRoot(transport, semrushWorkspaceId, projectId, log) { + const rootsByName = await indexLevelByName(transport, semrushWorkspaceId, projectId, '', log); + + const originId = rootsByName.get(DIMENSION.ORIGIN); + if (originId) { + return originId; + } + + const legacyId = rootsByName.get(LEGACY_AUTHORSHIP_ROOT_NAME); + if (legacyId) { + const legacyChildren = await indexLevelByName( + transport, + semrushWorkspaceId, + projectId, + legacyId, + log, + ); + const isAuthorshipShaped = [...legacyChildren.keys()] + .every((name) => LEGACY_AUTHORSHIP_ALLOWED_CHILDREN.has(name)); + if (isAuthorshipShaped) { + log?.info?.('resolveAuthorshipRoot: adopted legacy "source" root as "origin"', { + semrushWorkspaceId, projectId, legacyId, + }); + return legacyId; + } + log?.warn?.('resolveAuthorshipRoot: found a "source" root with non-authorship children; not adopting it', { + semrushWorkspaceId, projectId, legacyId, children: [...legacyChildren.keys()], + }); + } + const { byName } = await ensureChildren( transport, semrushWorkspaceId, projectId, '', - DIMENSION_ROOT_NAMES, + [DIMENSION.ORIGIN], log, ); + return /** @type {string} */ (byName.get(DIMENSION.ORIGIN)); +} + +/** + * Resolves the four dimension roots, creating any that a project is missing. + * Older projects predate this taxonomy entirely, so this is the seam that brings + * them forward on first touch. + * + * The authorship root (`origin`) is resolved separately, via + * {@link resolveAuthorshipRoot} — it is NEVER blind-created here, because a + * project still carrying the pre-rename `source` root must reuse it rather than + * gain a second, empty `origin` root. + * + * @param {object} transport - Serenity transport (Semrush proxy client). + * @param {string} semrushWorkspaceId + * @param {string} projectId + * @param {object} [log] - logger. + * @returns {Promise>} root name → tag id, for all four roots. + */ +export async function ensureDimensionRoots(transport, semrushWorkspaceId, projectId, log) { + const [{ byName }, authorshipRootId] = await Promise.all([ + ensureChildren(transport, semrushWorkspaceId, projectId, '', BLIND_ROOT_NAMES, log), + resolveAuthorshipRoot(transport, semrushWorkspaceId, projectId, log), + ]); + byName.set(DIMENSION.ORIGIN, authorshipRootId); return byName; } @@ -465,7 +549,7 @@ export async function provisionDimensionTree(transport, semrushWorkspaceId, proj * @param {object} transport - Serenity transport (Semrush proxy client). * @param {string} semrushWorkspaceId * @param {string} projectId - * @param {string} dimension - a closed dimension (`intent` / `source` / `type`). + * @param {string} dimension - a closed dimension (`intent` / `origin` / `type`). * @param {string} value - a bare value from that dimension's fixed vocabulary. * @param {object} [log] - logger. * @returns {Promise<{ id: string, rootId: string, created: boolean }>} `created` @@ -497,6 +581,45 @@ export async function ensureClosedValue( }; } +/** + * Shared resolution behind {@link resolveTypeValueInjection} and + * {@link resolveOriginValueInjection}: resolves (provisioning as needed) one + * closed dimension's root and full child vocabulary, and the wanted value's id + * within it. + * + * @param {object} transport - Serenity transport (Semrush proxy client). + * @param {string} semrushWorkspaceId + * @param {string} projectId + * @param {string} dimension - a closed dimension (`intent` / `origin` / `type`). + * @param {string} wantValue - a bare value from that dimension's fixed vocabulary. + * @param {object} [log] - logger. + * @returns {Promise<{ computedId: string, tagIds: string[] }>} `tagIds` is every + * id under the dimension's root — the caller's strip set. + */ +async function resolveClosedValueInjection( + transport, + semrushWorkspaceId, + projectId, + dimension, + wantValue, + log, +) { + const roots = await ensureDimensionRoots(transport, semrushWorkspaceId, projectId, log); + const rootId = rootIdOf(roots, dimension); + const { byName } = await ensureChildren( + transport, + semrushWorkspaceId, + projectId, + rootId, + [wantValue], + log, + ); + return { + computedId: /** @type {string} */ (byName.get(wantValue)), + tagIds: [...byName.values()], + }; +} + /** * Resolves the id-based injection of a server-computed `type` value into a * prompt write. Returns the wanted value's id plus EVERY id under the `type` @@ -519,18 +642,47 @@ export async function resolveTypeValueInjection( wantValue, log, ) { - const roots = await ensureDimensionRoots(transport, semrushWorkspaceId, projectId, log); - const typeRootId = rootIdOf(roots, DIMENSION.TYPE); - const { byName } = await ensureChildren( + const { computedId, tagIds } = await resolveClosedValueInjection( transport, semrushWorkspaceId, projectId, - typeRootId, - [wantValue], + DIMENSION.TYPE, + wantValue, log, ); - return { - computedId: /** @type {string} */ (byName.get(wantValue)), - typeTagIds: [...byName.values()], - }; + return { computedId, typeTagIds: tagIds }; +} + +/** + * Resolves the id-based injection of a server-computed `origin` value into a + * prompt write. Returns the wanted value's id plus EVERY id under the `origin` + * root, so the caller can strip any caller-supplied `origin` tag id (a client + * must never set the value itself on create; on update the caller re-injects + * the prompt's already-stored id instead of calling this with a freshly + * derived one — see `makeTypeInjector` in `handlers/prompts.js`). + * + * @param {object} transport - Serenity transport (Semrush proxy client). + * @param {string} semrushWorkspaceId + * @param {string} projectId + * @param {string} wantValue - the bare `origin` value (`ai` / `human`). + * @param {object} [log] - logger. + * @returns {Promise<{ computedId: string, originTagIds: string[] }>} `computedId` + * is always resolved — {@link ensureChildren} throws rather than leave a hole. + */ +export async function resolveOriginValueInjection( + transport, + semrushWorkspaceId, + projectId, + wantValue, + log, +) { + const { computedId, tagIds } = await resolveClosedValueInjection( + transport, + semrushWorkspaceId, + projectId, + DIMENSION.ORIGIN, + wantValue, + log, + ); + return { computedId, originTagIds: tagIds }; } diff --git a/test/support/prompts-storage.test.js b/test/support/prompts-storage.test.js index 389c792ee0..986ee4b4c1 100644 --- a/test/support/prompts-storage.test.js +++ b/test/support/prompts-storage.test.js @@ -30,6 +30,7 @@ import { isMissingIntentColumnError, findPromptsBlockingRegionRemoval, getIntentsByPromptIds, + resolveOriginForWrite, } from '../../src/support/prompts-storage.js'; use(chaiAsPromised); @@ -899,7 +900,10 @@ describe('prompts-storage', () => { expect(result).to.not.be.null; expect(result.regions).to.deep.equal([]); expect(result.status).to.equal('active'); - expect(result.origin).to.equal('human'); + // No fallback: the column has zero NULLs in production, so a null/ + // undefined `origin` is returned as-is rather than silently coerced to + // 'human' (which would have mislabeled it). + expect(result.origin).to.equal(undefined); expect(result.source).to.equal('config'); expect(result.category).to.be.null; expect(result.topic).to.be.null; @@ -2982,6 +2986,79 @@ describe('prompts-storage', () => { }); }); + describe('resolveOriginForWrite (WP-O2b: origin derived from the authenticated principal)', () => { + it('honors a valid asserted value from a service principal', () => { + expect(resolveOriginForWrite('ai', true)).to.equal('ai'); + expect(resolveOriginForWrite('human', true)).to.equal('human'); + }); + + it('falls back to human for an unrecognized value from a service principal (never rejects)', () => { + expect(resolveOriginForWrite('bogus', true)).to.equal('human'); + expect(resolveOriginForWrite(undefined, true)).to.equal('human'); + }); + + it('always overrides to human for a user principal, regardless of the asserted value', () => { + expect(resolveOriginForWrite('ai', false)).to.equal('human'); + expect(resolveOriginForWrite('human', false)).to.equal('human'); + expect(resolveOriginForWrite(undefined, false)).to.equal('human'); + }); + }); + + describe('upsertPrompts - origin derived from the authenticated principal', () => { + function makeUpsertClient() { + return { + from: (table) => { + if (table === 'prompts') { + return { + select: () => ({ + eq: () => ({ + eq: () => thenable({ data: [], error: null }), + }), + }), + insert: () => ({ + select: () => thenable({ data: [{ prompt_id: 'new-1' }], error: null }), + }), + update: () => ({ eq: () => thenable({ error: null }) }), + }; + } + return makeChain({}); + }, + }; + } + + it('honors a service principal\'s asserted origin', async () => { + const result = await upsertPrompts({ + organizationId: ORG_ID, + brandUuid: BRAND_UUID, + prompts: [{ prompt: 'New prompt', regions: ['us'], origin: 'ai' }], + postgrestClient: makeUpsertClient(), + isServicePrincipal: true, + }); + expect(result.prompts[0].origin).to.equal('ai'); + }); + + it('overrides a user principal\'s asserted origin to human', async () => { + const result = await upsertPrompts({ + organizationId: ORG_ID, + brandUuid: BRAND_UUID, + prompts: [{ prompt: 'New prompt', regions: ['us'], origin: 'ai' }], + postgrestClient: makeUpsertClient(), + isServicePrincipal: false, + }); + expect(result.prompts[0].origin).to.equal('human'); + }); + + it('defaults to human when isServicePrincipal is omitted', async () => { + const result = await upsertPrompts({ + organizationId: ORG_ID, + brandUuid: BRAND_UUID, + prompts: [{ prompt: 'New prompt', regions: ['us'], origin: 'ai' }], + postgrestClient: makeUpsertClient(), + }); + expect(result.prompts[0].origin).to.equal('human'); + }); + }); + describe('bulkDeletePrompts', () => { it('throws when postgrestClient has no from', async () => { await expect( diff --git a/test/support/serenity/fixtures/tag-tree.js b/test/support/serenity/fixtures/tag-tree.js index 5adcfe7ef2..7d15eb44d6 100644 --- a/test/support/serenity/fixtures/tag-tree.js +++ b/test/support/serenity/fixtures/tag-tree.js @@ -28,7 +28,7 @@ import { * does not exist yields an empty page, exactly as upstream does. * * The fixture deliberately carries a cross-dimension name collision: the - * sub-category `human` under `Running Shoes` and the `source` value `human` are + * sub-category `human` under `Running Shoes` and the `origin` value `human` are * different tags with the same bare name. Any handler that keys tags by name * rather than id collapses them, and the tests that use this tree catch it. */ @@ -36,7 +36,7 @@ import { export const TAG_IDS = Object.freeze({ categoryRoot: 'root-category', intentRoot: 'root-intent', - sourceRoot: 'root-source', + originRoot: 'root-origin', typeRoot: 'root-type', intentInformational: 'intent-informational', @@ -45,8 +45,8 @@ export const TAG_IDS = Object.freeze({ intentTransactional: 'intent-transactional', intentNavigational: 'intent-navigational', - sourceAi: 'source-ai', - sourceHuman: 'source-human', + originAi: 'origin-ai', + originHuman: 'origin-human', typeBranded: 'type-branded', typeNonBranded: 'type-non-branded', @@ -58,7 +58,7 @@ export const TAG_IDS = Object.freeze({ const ROOT_IDS = Object.freeze({ category: TAG_IDS.categoryRoot, intent: TAG_IDS.intentRoot, - source: TAG_IDS.sourceRoot, + origin: TAG_IDS.originRoot, type: TAG_IDS.typeRoot, }); @@ -70,7 +70,7 @@ const CLOSED_VALUE_IDS = Object.freeze({ Transactional: TAG_IDS.intentTransactional, Navigational: TAG_IDS.intentNavigational, }, - source: { ai: TAG_IDS.sourceAi, human: TAG_IDS.sourceHuman }, + origin: { ai: TAG_IDS.originAi, human: TAG_IDS.originHuman }, type: { branded: TAG_IDS.typeBranded, 'non-branded': TAG_IDS.typeNonBranded }, }); @@ -104,7 +104,7 @@ export function dimensionTreeLevels(extraLevels = {}) { })); const closedLevels = {}; - for (const dimension of ['intent', 'source', 'type']) { + for (const dimension of ['intent', 'origin', 'type']) { closedLevels[ROOT_IDS[dimension]] = CLOSED_DIMENSION_VALUES[dimension].map((value) => ( upstreamTag({ id: CLOSED_VALUE_IDS[dimension][value], @@ -125,7 +125,7 @@ export function dimensionTreeLevels(extraLevels = {}) { childrenCount: 1, path: CATEGORY_CRUMB, })], - // Depth 3. Shares its bare name with the `source` value `human`. + // Depth 3. Shares its bare name with the `origin` value `human`. [TAG_IDS.categoryRunningShoes]: [upstreamTag({ id: TAG_IDS.subCategoryHuman, name: 'human', diff --git a/test/support/serenity/handlers/markets-subworkspace.test.js b/test/support/serenity/handlers/markets-subworkspace.test.js index f082e68e96..977d244f76 100644 --- a/test/support/serenity/handlers/markets-subworkspace.test.js +++ b/test/support/serenity/handlers/markets-subworkspace.test.js @@ -32,9 +32,9 @@ import { TAG_IDS, dimensionTreeLevels, makeListProjectTagsStub } from '../fixtur use(chaiAsPromised); use(sinonChai); -// Every generated prompt carries the two standard values (source=ai, +// Every generated prompt carries the two standard values (origin=ai, // intent=Informational); the third tag is the per-prompt computed `type`. -const STANDARD_IDS = [TAG_IDS.sourceAi, TAG_IDS.intentInformational]; +const STANDARD_IDS = [TAG_IDS.originAi, TAG_IDS.intentInformational]; const BRAND = 'brand-1'; const WS = 'subworkspace-ws-1'; @@ -555,7 +555,7 @@ describe('markets-subworkspace handlers', () => { null, null, { - generateTopics: true, topicCap: 1, standardTags: ['source:ai'], publishMode: 'require', + generateTopics: true, topicCap: 1, standardTags: ['origin:ai'], publishMode: 'require', }, )).to.be.rejectedWith(/topics boom/); }); @@ -954,7 +954,7 @@ describe('markets-subworkspace handlers', () => { }); const result = await handleListTagsSubworkspace(transport, WS, { geoTargetId: 2840, languageCode: 'en' }, log); // Names are unique only per (project, parent): a sub-category `human` and - // the `source` value `human` are different tags. Keying by name would drop + // the `origin` value `human` are different tags. Keying by name would drop // one of them; keying by id keeps both. expect(result.items).to.deep.equal([ { id: 'prompt-id', name: 'human' }, diff --git a/test/support/serenity/handlers/prompts-subworkspace.test.js b/test/support/serenity/handlers/prompts-subworkspace.test.js index 37f33cb442..20c94b48cd 100644 --- a/test/support/serenity/handlers/prompts-subworkspace.test.js +++ b/test/support/serenity/handlers/prompts-subworkspace.test.js @@ -148,7 +148,8 @@ describe('prompts-subworkspace handlers', () => { }, log); expect(result.created).to.have.length(1); expect(result.created[0]).to.include({ semrushPromptId: 'new-prompt', geoTargetId: 2840 }); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WS, 'p-us-en', ['p'], ['tag-1']); + // `origin` is always injected (never client-settable), same as `type`. + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WS, 'p-us-en', ['p'], ['tag-1', TAG_IDS.originAi]); expect(transport.publishProject).to.have.been.calledOnceWith(WS, 'p-us-en'); }); @@ -178,13 +179,13 @@ describe('prompts-subworkspace handlers', () => { }], }, log, classifyByBrandMention); expect(result.created[0].tagIds).to.deep.equal([ - TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, + TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi, ]); expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly( WS, 'p-us-en', ['is Acme good?'], - [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded], + [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi], ); }); @@ -339,9 +340,10 @@ describe('prompts-subworkspace handlers', () => { }, log); expect(result.status).to.equal(200); expect(result.body.semrushPromptId).to.equal('new-prompt-by-id'); - expect(result.body.tagIds).to.deep.equal(['tag-cat-1']); + // No `origin` tag was already present, so the update falls back to `ai`. + expect(result.body.tagIds).to.deep.equal(['tag-cat-1', TAG_IDS.originAi]); expect(transport.deletePromptsByIds).to.have.been.calledWith(WS, 'p-us-en', ['old-id']); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WS, 'p-us-en', ['new'], ['tag-cat-1']); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WS, 'p-us-en', ['new'], ['tag-cat-1', TAG_IDS.originAi]); }); it('400s when the slice key is invalid', async () => { @@ -371,13 +373,13 @@ describe('prompts-subworkspace handlers', () => { }, log, classifyByBrandMention); expect(result.status).to.equal(200); expect(result.body.tagIds).to.deep.equal([ - TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, + TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi, ]); expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly( WS, 'p-us-en', ['now mentions Acme'], - [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded], + [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi], ); }); @@ -700,6 +702,6 @@ describe('prompts-subworkspace — defensive branch coverage', () => { languageCode: 'en', }, log); expect(result.status).to.equal(200); - expect(result.body.tagIds).to.deep.equal(['keep']); + expect(result.body.tagIds).to.deep.equal(['keep', TAG_IDS.originAi]); }); }); diff --git a/test/support/serenity/handlers/prompts.test.js b/test/support/serenity/handlers/prompts.test.js index e47b9fe4c8..e8089d1cc5 100644 --- a/test/support/serenity/handlers/prompts.test.js +++ b/test/support/serenity/handlers/prompts.test.js @@ -66,6 +66,31 @@ function fakeLog() { }; } +// `origin` is now injected UNCONDITIONALLY on every create/update, regardless +// of whether a `classifyPromptType` closure is supplied — see the-origin- +// dimension spec, WP-O2b item 1. These stubs answer a project that already +// has an `origin` root with `ai` resolved, so tests not focused on `origin` +// itself don't need to provision anything; `ORIGIN_AI_ID` is the id every +// create appends and every update falls back to when it finds no stored one. +const ORIGIN_AI_ID = 'origin-ai'; +function makeOriginResolvedStubs() { + return { + // All four roots already exist (only `origin` matters to these tests, + // but `ensureDimensionRoots` resolves all four on every call) so no + // create is ever attempted. + listProjectTags: makeListProjectTagsStub({ + '': [ + { id: 'root-category', name: 'category', children_count: 0 }, + { id: 'root-intent', name: 'intent', children_count: 0 }, + { id: 'root-origin', name: 'origin', children_count: 1 }, + { id: 'root-type', name: 'type', children_count: 0 }, + ], + 'root-origin': [{ id: ORIGIN_AI_ID, name: 'ai', parent_id: 'root-origin' }], + }), + createProjectTags: sinon.stub(), + }; +} + describe('handlers/prompts.js — handleListPrompts', () => { it('400s when geoTargetId is missing', async () => { const transport = {}; @@ -278,11 +303,11 @@ describe('handlers/prompts.js — handleListPrompts', () => { ], }, { - id: TAG_IDS.sourceHuman, + id: TAG_IDS.originHuman, name: 'human', children_count: 0, - parent_id: TAG_IDS.sourceRoot, - path: [{ id: TAG_IDS.sourceRoot, name: 'source' }], + parent_id: TAG_IDS.originRoot, + path: [{ id: TAG_IDS.originRoot, name: 'origin' }], }, ], }], @@ -296,9 +321,9 @@ describe('handlers/prompts.js — handleListPrompts', () => { const { tags } = result.items[0]; expect(tags).to.have.lengthOf(2); - expect(tags.map((t) => t.path[0].name)).to.deep.equal(['category', 'source']); + expect(tags.map((t) => t.path[0].name)).to.deep.equal(['category', 'origin']); expect(tags[0].parentId).to.equal(TAG_IDS.categoryRunningShoes); - expect(tags[1].parentId).to.equal(TAG_IDS.sourceRoot); + expect(tags[1].parentId).to.equal(TAG_IDS.originRoot); // The deprecated name-keyed view collapses them; only one id survives. expect(Object.keys(result.items[0].tagMap)).to.deep.equal(['human']); // Listing prompts costs exactly ONE upstream call — no tag-tree walk. @@ -553,6 +578,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { + ...makeOriginResolvedStubs(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'hello' }], existing_count: 0, }), @@ -571,9 +597,9 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { geoTargetId: 2840, languageCode: 'en', text: 'hello', - tagIds: ['tag-cat-1', 'tag-child-1'], + tagIds: ['tag-cat-1', 'tag-child-1', ORIGIN_AI_ID], }); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['tag-cat-1', 'tag-child-1']); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['tag-cat-1', 'tag-child-1', ORIGIN_AI_ID]); expect(transport.publishProject).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en'); }); @@ -643,6 +669,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { + ...makeOriginResolvedStubs(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 0, items: [], existing_count: 1, }), @@ -656,8 +683,8 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }, fakeLog()); expect(result.created[0].semrushPromptId).to.equal(''); - expect(result.created[0].tagIds).to.deep.equal(['keep']); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep']); + expect(result.created[0].tagIds).to.deep.equal(['keep', ORIGIN_AI_ID]); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep', ORIGIN_AI_ID]); }); it('returns empty semrushPromptId (not the string "undefined") when createPromptsByIds returns an item with no id', async () => { @@ -666,6 +693,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { + ...makeOriginResolvedStubs(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ name: 'hello' }], }), @@ -687,6 +715,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { + ...makeOriginResolvedStubs(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'hello' }], }), @@ -703,8 +732,8 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }], }, fakeLog()); - expect(result.created[0].tagIds).to.deep.equal(['keep']); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep']); + expect(result.created[0].tagIds).to.deep.equal(['keep', ORIGIN_AI_ID]); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep', ORIGIN_AI_ID]); }); it('caps a bulk-create tagIds array at MAX_TAG_IDS (50), mirroring the list-read query cap', async () => { @@ -713,6 +742,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { + ...makeOriginResolvedStubs(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'hello' }], }), @@ -726,8 +756,10 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }], }, fakeLog()); - expect(result.created[0].tagIds).to.have.lengthOf(50); - expect(result.created[0].tagIds).to.deep.equal(tooMany.slice(0, 50)); + // Capped at 50 by sanitizeTagIds BEFORE the unified layer appends the + // (never client-settable) computed `origin` tag. + expect(result.created[0].tagIds).to.have.lengthOf(51); + expect(result.created[0].tagIds).to.deep.equal([...tooMany.slice(0, 50), ORIGIN_AI_ID]); }); it('skips a create row when tagIds sanitizes to empty (every entry malformed)', async () => { @@ -753,6 +785,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([usEn]); const transport = { + ...makeOriginResolvedStubs(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'good' }], }), @@ -790,6 +823,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { const dataAccess = makeDataAccess([project]); const err = Object.assign(new Error('rate limited'), { status: 429 }); const transport = { + ...makeOriginResolvedStubs(), createPromptsByIds: sinon.stub().rejects(err), publishProject: sinon.stub().resolves(), }; @@ -822,6 +856,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { + ...makeOriginResolvedStubs(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'ok' }], }), @@ -999,6 +1034,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { dataAccess.BrandSemrushProject.findBySlice.resolves(project); const transport = { + ...makeOriginResolvedStubs(), deletePromptsByIds: sinon.stub().resolves(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'next' }], existing_count: 0, @@ -1024,10 +1060,12 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { geoTargetId: 2840, languageCode: 'en', text: 'next', - tagIds: ['tag-cat-1'], + // No `origin` tag was already present, so the update falls back to `ai` + // rather than leave the prompt with none. + tagIds: ['tag-cat-1', ORIGIN_AI_ID], }); expect(transport.deletePromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['sem-1']); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['next'], ['tag-cat-1']); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['next'], ['tag-cat-1', ORIGIN_AI_ID]); }); it('drops falsy tagIds entries on PATCH before sending to the id-based endpoint', async () => { @@ -1038,6 +1076,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { dataAccess.BrandSemrushProject.findBySlice.resolves(project); const transport = { + ...makeOriginResolvedStubs(), deletePromptsByIds: sinon.stub().resolves(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'next' }], existing_count: 0, @@ -1057,8 +1096,8 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { fakeLog(), ); - expect(result.body.tagIds).to.deep.equal(['keep']); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['next'], ['keep']); + expect(result.body.tagIds).to.deep.equal(['keep', ORIGIN_AI_ID]); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['next'], ['keep', ORIGIN_AI_ID]); }); it('drops malformed tagIds entries on PATCH like validateParentIdFormat does for parentId', async () => { @@ -1069,6 +1108,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { dataAccess.BrandSemrushProject.findBySlice.resolves(project); const transport = { + ...makeOriginResolvedStubs(), deletePromptsByIds: sinon.stub().resolves(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'next' }], @@ -1092,8 +1132,8 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { fakeLog(), ); - expect(result.body.tagIds).to.deep.equal(['keep']); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['next'], ['keep']); + expect(result.body.tagIds).to.deep.equal(['keep', ORIGIN_AI_ID]); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['next'], ['keep', ORIGIN_AI_ID]); }); it('400s when tagIds sanitizes to empty (every entry malformed)', async () => { @@ -1162,6 +1202,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { dataAccess.BrandSemrushProject.findBySlice.resolves(project); const transport = { + ...makeOriginResolvedStubs(), // listPromptsByTags is no longer called from PATCH — wiring it as a // stub lets us assert callCount(0) so a regression that brings the // walk back fails this test. @@ -1191,7 +1232,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { geoTargetId: 2840, languageCode: 'en', text: 'new text', - tagIds: ['tag-fresh'], + tagIds: ['tag-fresh', ORIGIN_AI_ID], }); expect(transport.listPromptsByTags).to.have.callCount(0); expect(transport.deletePromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['sem-1']); @@ -1209,6 +1250,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { // A generic Error with .status=404 must NOT trip the idempotent path. const err = new SerenityTransportError(404, 'not found'); const transport = { + ...makeOriginResolvedStubs(), deletePromptsByIds: sinon.stub().rejects(err), createPromptsByIds: sinon.stub(), }; @@ -1241,6 +1283,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { dataAccess.BrandSemrushProject.findBySlice.resolves(project); const transport = { + ...makeOriginResolvedStubs(), deletePromptsByIds: sinon.stub().resolves(), createPromptsByIds: sinon.stub().resolves({}), // no items publishProject: sinon.stub().resolves(), @@ -1271,6 +1314,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { const err = Object.assign(new Error('upstream 503'), { status: 503 }); const transport = { + ...makeOriginResolvedStubs(), deletePromptsByIds: sinon.stub().rejects(err), createPromptsByIds: sinon.stub().resolves({ items: [{ id: 'should-not-happen' }] }), }; @@ -1300,6 +1344,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { const createErr = Object.assign(new Error('unknown tag id: bogus'), { status: 500 }); const transport = { + ...makeOriginResolvedStubs(), deletePromptsByIds: sinon.stub().resolves(), createPromptsByIds: sinon.stub().rejects(createErr), publishProject: sinon.stub().resolves(), @@ -1680,6 +1725,7 @@ describe('handlers/prompts.js — tag cache invalidation (Important #6)', () => } = setupCtx; // Mutation: handleCreatePrompts pushes a new prompt → invalidate. + Object.assign(transport, makeOriginResolvedStubs()); transport.createPromptsByIds = sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'sem-new', name: 'fresh' }], }); @@ -1709,6 +1755,7 @@ describe('handlers/prompts.js — tag cache invalidation (Important #6)', () => handleListTags, transport, dataAccess, } = setupCtx; + Object.assign(transport, makeOriginResolvedStubs()); transport.deletePromptsByIds = sinon.stub().resolves(); transport.createPromptsByIds = sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'sem-new', name: 'updated' }], @@ -1818,13 +1865,13 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) }, fakeLog(), classifyByBrandMention); expect(result.created[0].tagIds).to.deep.equal([ - TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, + TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi, ]); expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly( WORKSPACE, 'proj-us-en', ['is Acme good?'], - [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded], + [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi], ); // The whole taxonomy already exists, so nothing is provisioned. expect(transport.createProjectTags).to.not.have.been.called; @@ -1850,7 +1897,7 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) }, fakeLog(), classifyByBrandMention); expect(result.created[0].tagIds).to.deep.equal([ - TAG_IDS.categoryRunningShoes, TAG_IDS.typeNonBranded, + TAG_IDS.categoryRunningShoes, TAG_IDS.typeNonBranded, TAG_IDS.originAi, ]); }); @@ -1888,7 +1935,9 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) }], }, fakeLog(), classifyByBrandMention); - expect(result.created[0].tagIds).to.deep.equal([decoyCategoryId, TAG_IDS.typeBranded]); + expect(result.created[0].tagIds).to.deep.equal([ + decoyCategoryId, TAG_IDS.typeBranded, TAG_IDS.originAi, + ]); }); // Projects that predate the taxonomy carry none of it; the first write @@ -1911,15 +1960,23 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) }], }, fakeLog(), classifyByBrandMention); - // The four roots are created at the root level, then `branded` beneath - // the freshly-minted `type` root. - expect(createProjectTags.firstCall.args[2]).to.deep.equal([ - 'category', 'intent', 'source', 'type', + // Roots (the three blind ones, plus `origin` via its own resolver call) + // are created at the root level, then `branded` beneath the freshly- + // minted `type` root and `ai` beneath the freshly-minted `origin` root. + const rootCalls = createProjectTags.getCalls() + .filter((c) => c.args[3].parentId === undefined); + expect(rootCalls.map((c) => c.args[2]).flat().sort()).to.deep.equal( + ['category', 'intent', 'origin', 'type'].sort(), + ); + const typeValueCall = createProjectTags.getCalls() + .find((c) => c.args[3].parentId === 'created::type'); + expect(typeValueCall.args[2]).to.deep.equal(['branded']); + const originValueCall = createProjectTags.getCalls() + .find((c) => c.args[3].parentId === 'created::origin'); + expect(originValueCall.args[2]).to.deep.equal(['ai']); + expect(result.created[0].tagIds).to.deep.equal([ + 'tag-cat-1', 'created:created::type:branded', 'created:created::origin:ai', ]); - expect(createProjectTags.firstCall.args[3]).to.deep.equal({}); - expect(createProjectTags.secondCall.args[2]).to.deep.equal(['branded']); - expect(createProjectTags.secondCall.args[3]).to.deep.equal({ parentId: 'created::type' }); - expect(result.created[0].tagIds).to.deep.equal(['tag-cat-1', 'created:created::type:branded']); }); }); @@ -1944,8 +2001,10 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) }, fakeLog(), classifyByBrandMention); expect(result.status).to.equal(200); + // No `origin` tag was already present in the request's tagIds, so the + // update falls back to `ai` rather than leave the prompt with none. expect(result.body.tagIds).to.deep.equal([ - TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, + TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi, ]); }); @@ -1981,32 +2040,40 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) }; const inject = makeTypeInjector(transport, WORKSPACE, classifyByBrandMention, fakeLog()); - // Resolving one (project, type) key reads two levels: the roots, then the - // children of the `type` root. + // `origin` is resolved once (per project) and cached across every call + // regardless of `type`; `type` is resolved once per distinct (project, + // type) key. Both resolutions are two-level reads (roots + the + // dimension root's children). const a = await inject('proj-1', { text: 'love Acme', geoTargetId: 2840, tagIds: ['x'] }); - expect(transport.listProjectTags).to.have.callCount(2); + const readsAfterFirst = transport.listProjectTags.callCount; // Same project + same computed type => served from cache, no new reads. const b = await inject('proj-1', { text: 'Acme rocks', geoTargetId: 2840, tagIds: ['y'] }); - expect(transport.listProjectTags).to.have.callCount(2); - expect(a.tagIds).to.deep.equal(['x', TAG_IDS.typeBranded]); - expect(b.tagIds).to.deep.equal(['y', TAG_IDS.typeBranded]); + expect(transport.listProjectTags).to.have.callCount(readsAfterFirst); + expect(a.tagIds).to.deep.equal(['x', TAG_IDS.typeBranded, TAG_IDS.originAi]); + expect(b.tagIds).to.deep.equal(['y', TAG_IDS.typeBranded, TAG_IDS.originAi]); - // A different computed type is a new cache key => one more resolution. + // A different computed type is a new `type` cache key => more `type` + // reads, but `origin` is still served from cache (already resolved). const c = await inject('proj-1', { text: 'best running shoes', geoTargetId: 2840, tagIds: ['z'] }); - expect(transport.listProjectTags).to.have.callCount(4); - expect(c.tagIds).to.deep.equal(['z', TAG_IDS.typeNonBranded]); + expect(transport.listProjectTags.callCount).to.be.greaterThan(readsAfterFirst); + expect(c.tagIds).to.deep.equal(['z', TAG_IDS.typeNonBranded, TAG_IDS.originAi]); expect(transport.createProjectTags).to.not.have.been.called; }); - it('passes the input through untouched when no classifier is supplied', async () => { - const transport = { listProjectTags: sinon.stub(), createProjectTags: sinon.stub() }; + it('always injects `origin` even when no `type` classifier is supplied', async () => { + const transport = { + listProjectTags: makeListProjectTagsStub(), + createProjectTags: sinon.stub(), + }; const inject = makeTypeInjector(transport, WORKSPACE, undefined, fakeLog()); const out = await inject('proj-1', { text: 'anything', geoTargetId: 2840, tagIds: ['x'] }); - expect(out.tagIds).to.deep.equal(['x']); - expect(transport.listProjectTags).to.not.have.been.called; + // `type` is untouched (no classifier), but `origin` is always injected — + // it is never client-settable, unlike `type` which merely skips + // classification when no closure is supplied. + expect(out.tagIds).to.deep.equal(['x', TAG_IDS.originAi]); }); }); }); diff --git a/test/support/serenity/handlers/tags.test.js b/test/support/serenity/handlers/tags.test.js index 0e5a2e68d5..c1601e4114 100644 --- a/test/support/serenity/handlers/tags.test.js +++ b/test/support/serenity/handlers/tags.test.js @@ -142,16 +142,24 @@ describe('serenity tags handler (POST /serenity/tags)', () => { }); it('provisions the four dimension roots on a project that predates the taxonomy', async () => { - const createProjectTags = sinon.stub(); - createProjectTags.onFirstCall().resolves([ - { id: 'r-category', name: 'category' }, - { id: 'r-intent', name: 'intent' }, - { id: 'r-source', name: 'source' }, - { id: 'r-type', name: 'type' }, - ]); - createProjectTags.onSecondCall().resolves([ - { id: 'new-cat', name: 'Footwear', parent_id: 'r-category' }, - ]); + // `origin` is resolved via its own seam (a separate create call), not + // blind-created alongside the other three roots — match by requested + // names instead of call order. + const createProjectTags = sinon.stub().callsFake((ws, pid, names, opts) => { + if (names.includes('category')) { + return Promise.resolve([ + { id: 'r-category', name: 'category' }, + { id: 'r-intent', name: 'intent' }, + { id: 'r-type', name: 'type' }, + ]); + } + if (names.includes('origin')) { + return Promise.resolve([{ id: 'r-origin', name: 'origin' }]); + } + return Promise.resolve([ + { id: 'new-cat', name: names[0], parent_id: opts.parentId }, + ]); + }); const transport = makeEmptyTreeTransport({ createProjectTags }); const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); @@ -165,9 +173,9 @@ describe('serenity tags handler (POST /serenity/tags)', () => { ); expect(res.status).to.equal(201); - expect(createProjectTags.firstCall.args[2]).to.deep.equal(['category', 'intent', 'source', 'type']); - expect(createProjectTags.secondCall.args[2]).to.deep.equal(['Footwear']); - expect(createProjectTags.secondCall.args[3]).to.deep.equal({ parentId: 'r-category' }); + expect(createProjectTags).to.have.been.calledWith(WORKSPACE, 'proj-1', ['category', 'intent', 'type'], {}); + expect(createProjectTags).to.have.been.calledWith(WORKSPACE, 'proj-1', ['origin'], {}); + expect(createProjectTags).to.have.been.calledWith(WORKSPACE, 'proj-1', ['Footwear'], { parentId: 'r-category' }); expect(res.body).to.include({ id: 'new-cat', parentId: 'r-category' }); }); @@ -190,10 +198,9 @@ describe('serenity tags handler (POST /serenity/tags)', () => { expect(err, 'the handler must reject').to.not.equal(null); expect(err.status).to.equal(502); expect(err.message).to.match(/did not persist the tag\(s\)/); - // The roots were attempted; the category itself never was. - expect(transport.createProjectTags).to.have.been.calledOnce; - expect(transport.createProjectTags.firstCall.args[2]) - .to.deep.equal(['category', 'intent', 'source', 'type']); + // The roots were attempted (the three blind ones plus `origin`'s own + // resolver call); the category itself never was. + expect(transport.createProjectTags).to.have.been.calledWith(WORKSPACE, 'proj-1', ['category', 'intent', 'type'], {}); }); it('502s when the upstream create response carries no usable id', async () => { @@ -231,7 +238,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { it('400s a name that shadows a reserved dimension root', async () => { const transport = makeTransport(); const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); - for (const name of ['category', 'intent', 'source', 'type']) { + for (const name of ['category', 'intent', 'origin', 'type']) { // eslint-disable-next-line no-await-in-loop await expect(handler.handleCreateTag( transport, @@ -321,11 +328,11 @@ describe('serenity tags handler (POST /serenity/tags)', () => { it('creates a closed-dimension value under its root when absent (200, created:true)', async () => { // The `source` root exists but is empty, so `ai` must be minted beneath it. const levels = dimensionTreeLevels(); - levels[TAG_IDS.sourceRoot] = []; + levels[TAG_IDS.originRoot] = []; const transport = makeTransport({ listProjectTags: makeListProjectTagsStub(levels), createProjectTags: sinon.stub().resolves([ - { id: 'tag-source-ai', name: 'ai', parent_id: TAG_IDS.sourceRoot }, + { id: 'tag-origin-ai', name: 'ai', parent_id: TAG_IDS.originRoot }, ]), }); const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); @@ -335,17 +342,17 @@ describe('serenity tags handler (POST /serenity/tags)', () => { BRAND, WORKSPACE, { - type: 'source', name: 'ai', geoTargetId: 2840, languageCode: 'en', + type: 'origin', name: 'ai', geoTargetId: 2840, languageCode: 'en', }, fakeLog(), ); expect(res.status).to.equal(200); expect(res.body).to.include({ - type: 'source', name: 'ai', id: 'tag-source-ai', parentId: TAG_IDS.sourceRoot, created: true, + type: 'origin', name: 'ai', id: 'tag-origin-ai', parentId: TAG_IDS.originRoot, created: true, }); // A closed value is a CHILD of its dimension root, never a root itself. expect(transport.createProjectTags) - .to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-1', ['ai'], { parentId: TAG_IDS.sourceRoot }); + .to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-1', ['ai'], { parentId: TAG_IDS.originRoot }); }); it('resolves an EXISTING closed-dimension value without creating a duplicate (200, created:false)', async () => { @@ -415,7 +422,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { BRAND, WORKSPACE, { - type: 'source', name: 'ai', geoTargetId: 2840, languageCode: 'en', parentId: 'root-1', + type: 'origin', name: 'ai', geoTargetId: 2840, languageCode: 'en', parentId: 'root-1', }, fakeLog(), )).to.be.rejected.then((err) => expect(err.status).to.equal(400)); @@ -560,24 +567,24 @@ describe('serenity tags handler (POST /serenity/tags)', () => { // The `source` root exists but is empty, so `ai` must be minted beneath it, // and a newly minted value must be published so it is live rather than draft. const levels = dimensionTreeLevels(); - levels[TAG_IDS.sourceRoot] = []; + levels[TAG_IDS.originRoot] = []; const transport = makeTransport({ listProjectTags: makeListProjectTagsStub(levels), createProjectTags: sinon.stub().resolves([ - { id: 'tag-source-ai', name: 'ai', parent_id: TAG_IDS.sourceRoot }, + { id: 'tag-origin-ai', name: 'ai', parent_id: TAG_IDS.originRoot }, ]), }); const res = await handler.handleCreateTagSubworkspace( transport, WORKSPACE, { - type: 'source', name: 'ai', geoTargetId: 2840, languageCode: 'en', + type: 'origin', name: 'ai', geoTargetId: 2840, languageCode: 'en', }, fakeLog(), ); expect(res.status).to.equal(200); expect(res.body).to.include({ - type: 'source', name: 'ai', id: 'tag-source-ai', parentId: TAG_IDS.sourceRoot, created: true, + type: 'origin', name: 'ai', id: 'tag-origin-ai', parentId: TAG_IDS.originRoot, created: true, }); expect(transport.publishProject).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-sub-1'); }); @@ -728,7 +735,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { dataAccess, BRAND, WORKSPACE, - { ...validBody, parentId: TAG_IDS.sourceHuman }, + { ...validBody, parentId: TAG_IDS.originHuman }, fakeLog(), ).then(() => null, (e) => e); @@ -879,7 +886,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { it('400s on a rename to a reserved dimension root name', async () => { const transport = makeTransport(); const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); - for (const name of ['category', 'intent', 'source', 'type']) { + for (const name of ['category', 'intent', 'origin', 'type']) { // eslint-disable-next-line no-await-in-loop await expect(handler.handleUpdateTag( transport, @@ -1133,13 +1140,13 @@ describe('serenity tags handler (POST /serenity/tags)', () => { dataAccess, BRAND, WORKSPACE, - TAG_IDS.sourceHuman, + TAG_IDS.originHuman, { name: 'manual', geoTargetId: 2840, languageCode: 'en' }, fakeLog(), ).then(() => null, (e) => e); expect(err.status).to.equal(400); - expect(err.message).to.match(/closed "source" dimension cannot be renamed or re-parented/); + expect(err.message).to.match(/closed "origin" dimension cannot be renamed or re-parented/); expect(transport.updateProjectTag).to.not.have.been.called; }); @@ -1154,7 +1161,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { TAG_IDS.categoryRunningShoes, { name: 'Running Shoes', - parentId: TAG_IDS.sourceRoot, + parentId: TAG_IDS.originRoot, geoTargetId: 2840, languageCode: 'en', }, diff --git a/test/support/serenity/prompt-tags.test.js b/test/support/serenity/prompt-tags.test.js index ed0b188a36..50f37607ca 100644 --- a/test/support/serenity/prompt-tags.test.js +++ b/test/support/serenity/prompt-tags.test.js @@ -15,7 +15,7 @@ import { expect } from 'chai'; import { DIMENSION, DIMENSION_ROOT_NAMES, - SOURCE_VALUE, + ORIGIN_VALUE, INTENT_VALUE, TYPE_VALUE, CLOSED_DIMENSION_VALUES, @@ -31,13 +31,13 @@ import { describe('serenity prompt-tags taxonomy', () => { describe('dimension roots', () => { it('has exactly four roots, all bare-named', () => { - expect([...DIMENSION_ROOT_NAMES]).to.deep.equal(['category', 'intent', 'source', 'type']); + expect([...DIMENSION_ROOT_NAMES]).to.deep.equal(['category', 'intent', 'origin', 'type']); DIMENSION_ROOT_NAMES.forEach((n) => expect(n).to.not.include(':')); }); it('splits the roots into one open and three closed dimensions', () => { expect([...OPEN_DIMENSIONS]).to.deep.equal([DIMENSION.CATEGORY]); - expect([...CLOSED_DIMENSIONS]).to.deep.equal(['intent', 'source', 'type']); + expect([...CLOSED_DIMENSIONS]).to.deep.equal(['intent', 'origin', 'type']); expect([...ALL_DIMENSIONS].sort()).to.deep.equal([...DIMENSION_ROOT_NAMES].sort()); }); @@ -62,10 +62,10 @@ describe('serenity prompt-tags taxonomy', () => { expect(INTENT_VALUE.NAVIGATIONAL).to.equal('Navigational'); }); - it('carries the source and type vocabularies', () => { - expect([...closedValuesOf(DIMENSION.SOURCE)]).to.deep.equal(['ai', 'human']); + it('carries the origin and type vocabularies', () => { + expect([...closedValuesOf(DIMENSION.ORIGIN)]).to.deep.equal(['ai', 'human']); expect([...closedValuesOf(DIMENSION.TYPE)]).to.deep.equal(['branded', 'non-branded']); - expect(SOURCE_VALUE.AI).to.equal('ai'); + expect(ORIGIN_VALUE.AI).to.equal('ai'); expect(TYPE_VALUE.NON_BRANDED).to.equal('non-branded'); }); @@ -86,9 +86,9 @@ describe('serenity prompt-tags taxonomy', () => { }); describe('STANDARD_PROMPT_TAG_VALUES', () => { - it('seeds source=ai + intent=Informational only (type is classified per prompt)', () => { + it('seeds origin=ai + intent=Informational only (type is classified per prompt)', () => { expect(STANDARD_PROMPT_TAG_VALUES.map((t) => [t.dimension, t.name])).to.deep.equal([ - ['source', 'ai'], + ['origin', 'ai'], ['intent', 'Informational'], ]); }); diff --git a/test/support/serenity/tag-tree.test.js b/test/support/serenity/tag-tree.test.js index 6d69f48555..7733873b7d 100644 --- a/test/support/serenity/tag-tree.test.js +++ b/test/support/serenity/tag-tree.test.js @@ -260,7 +260,7 @@ describe('serenity tag-tree', () => { createProjectTags: sinon.stub(), }; const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); - expect([...roots.keys()]).to.deep.equal(['category', 'intent', 'source', 'type']); + expect([...roots.keys()]).to.deep.equal(['category', 'intent', 'origin', 'type']); expect(transport.createProjectTags).to.not.have.been.called; }); @@ -268,10 +268,83 @@ describe('serenity tag-tree', () => { const { listProjectTags, createProjectTags } = makeProvisioningTransportStubs(); const transport = { listProjectTags, createProjectTags }; const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); - expect(createProjectTags).to.have.been.calledOnce; - expect(createProjectTags.firstCall.args[2]) - .to.deep.equal(['category', 'intent', 'source', 'type']); + // `origin` is resolved (and, absent a legacy `source` root, created) via its + // own seam rather than blind-created alongside the other three roots — two + // create calls, not one. + expect(createProjectTags).to.have.been.calledTwice; + const createdNames = createProjectTags.getCalls().flatMap((c) => c.args[2]); + expect(createdNames.sort()).to.deep.equal(['category', 'intent', 'origin', 'type'].sort()); expect(roots.get('type')).to.equal('created::type'); + expect(roots.get('origin')).to.equal('created::origin'); + }); + + it('adopts an existing legacy "source" root as "origin" instead of minting a second root', async () => { + const levels = { + '': [ + { id: 'r-category', name: 'category' }, + { id: 'r-intent', name: 'intent' }, + { id: 'r-source', name: 'source', children_count: 2 }, + { id: 'r-type', name: 'type' }, + ], + 'r-source': [ + { id: 'source-ai', name: 'ai', parent_id: 'r-source' }, + { id: 'source-human', name: 'human', parent_id: 'r-source' }, + ], + }; + const listProjectTags = makeListProjectTagsStub(levels); + const createProjectTags = sinon.stub(); + const transport = { listProjectTags, createProjectTags }; + + const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); + + expect(roots.get('origin')).to.equal('r-source'); + expect(createProjectTags).to.not.have.been.called; + }); + + it('does NOT adopt a "source" root whose children are not a subset of {ai, human}', async () => { + const levels = { + '': [ + { id: 'r-category', name: 'category' }, + { id: 'r-intent', name: 'intent' }, + { id: 'r-source', name: 'source', children_count: 1 }, + { id: 'r-type', name: 'type' }, + ], + 'r-source': [ + { id: 'source-other', name: 'some-unrelated-value', parent_id: 'r-source' }, + ], + }; + const listProjectTags = makeListProjectTagsStub(levels); + const createProjectTags = sinon.stub().callsFake( + (_ws, _proj, names, options = {}) => Promise.resolve( + names.map((name) => ({ id: `made:${name}`, name, parent_id: options.parentId || null })), + ), + ); + const transport = { listProjectTags, createProjectTags }; + + const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); + + // A NEW `origin` root is minted; the unrelated `source` root is left alone. + expect(roots.get('origin')).to.equal('made:origin'); + expect(createProjectTags).to.have.been.calledWithExactly(WS, PROJECT, ['origin'], {}); + }); + + it('resolves an "origin" root directly when a project already has one', async () => { + const levels = { + '': [ + { id: 'r-category', name: 'category' }, + { id: 'r-intent', name: 'intent' }, + { id: 'r-origin', name: 'origin' }, + { id: 'r-type', name: 'type' }, + ], + }; + const listProjectTags = makeListProjectTagsStub(levels); + const createProjectTags = sinon.stub(); + const transport = { listProjectTags, createProjectTags }; + + const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); + + expect(roots.get('origin')).to.equal('r-origin'); + expect(createProjectTags).to.not.have.been.called; }); }); @@ -284,7 +357,7 @@ describe('serenity tag-tree', () => { const { roots, values } = await provisionDimensionTree(transport, WS, PROJECT, fakeLog()); expect(roots.get('category')).to.equal(TAG_IDS.categoryRoot); - expect([...values.keys()]).to.deep.equal(['intent', 'source', 'type']); + expect([...values.keys()]).to.deep.equal(['intent', 'origin', 'type']); // The open dimension's children are customer content, never provisioned. expect(values.has('category')).to.equal(false); expect([...values.get('intent').keys()]).to.deep.equal([ @@ -298,9 +371,10 @@ describe('serenity tag-tree', () => { const { listProjectTags, createProjectTags } = makeProvisioningTransportStubs(); const transport = { listProjectTags, createProjectTags }; const { values } = await provisionDimensionTree(transport, WS, PROJECT, fakeLog()); - // Roots first, then one call per closed dimension. - expect(createProjectTags).to.have.callCount(4); - expect(values.get('source').get('ai')).to.equal('created:created::source:ai'); + // Roots first — one call for the three blind roots plus one for `origin`'s + // own resolver — then one call per closed dimension. + expect(createProjectTags).to.have.callCount(5); + expect(values.get('origin').get('ai')).to.equal('created:created::origin:ai'); expect(values.get('intent').get('Navigational')) .to.equal('created:created::intent:Navigational'); }); @@ -313,7 +387,7 @@ describe('serenity tag-tree', () => { '': [ { id: 'r-cat', name: 'category', children_count: 0 }, { id: 'r-int', name: 'intent', children_count: 0 }, - { id: 'r-src', name: 'source', children_count: 0 }, + { id: 'r-src', name: 'origin', children_count: 0 }, ], 'r-int': [], 'r-src': [], @@ -337,8 +411,8 @@ describe('serenity tag-tree', () => { const listProjectTags = makeListProjectTagsStub(); const transport = { listProjectTags, createProjectTags: sinon.stub() }; const { roots, values } = await provisionDimensionTree(transport, WS, PROJECT, fakeLog()); - expect([...roots.keys()]).to.have.members(['category', 'intent', 'source', 'type']); - expect(values.get('source')?.get('ai')).to.equal(TAG_IDS.sourceAi); + expect([...roots.keys()]).to.have.members(['category', 'intent', 'origin', 'type']); + expect(values.get('origin')?.get('ai')).to.equal(TAG_IDS.originAi); expect(values.get('type')?.get('branded')).to.equal(TAG_IDS.typeBranded); // The open `category` root is provisioned but its children are customer content. expect(values.has('category')).to.equal(false); @@ -352,25 +426,25 @@ describe('serenity tag-tree', () => { listProjectTags: makeListProjectTagsStub(), createProjectTags: sinon.stub(), }; - const res = await ensureClosedValue(transport, WS, PROJECT, 'source', 'ai', fakeLog()); + const res = await ensureClosedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()); expect(res).to.deep.equal({ - id: TAG_IDS.sourceAi, rootId: TAG_IDS.sourceRoot, created: false, + id: TAG_IDS.originAi, rootId: TAG_IDS.originRoot, created: false, }); expect(transport.createProjectTags).to.not.have.been.called; }); it('creates a missing value under its root and reports created:true', async () => { const levels = dimensionTreeLevels(); - levels[TAG_IDS.sourceRoot] = []; + levels[TAG_IDS.originRoot] = []; const transport = { listProjectTags: makeListProjectTagsStub(levels), createProjectTags: sinon.stub().resolves([ - { id: 'made-ai', name: 'ai', parent_id: TAG_IDS.sourceRoot }, + { id: 'made-ai', name: 'ai', parent_id: TAG_IDS.originRoot }, ]), }; - const res = await ensureClosedValue(transport, WS, PROJECT, 'source', 'ai', fakeLog()); + const res = await ensureClosedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()); expect(res).to.deep.equal({ - id: 'made-ai', rootId: TAG_IDS.sourceRoot, created: true, + id: 'made-ai', rootId: TAG_IDS.originRoot, created: true, }); }); @@ -380,7 +454,7 @@ describe('serenity tag-tree', () => { // The create echoes nothing, so no root id is ever learned. createProjectTags: sinon.stub().resolves([]), }; - const err = await ensureClosedValue(transport, WS, PROJECT, 'source', 'ai', fakeLog()) + const err = await ensureClosedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()) .then(() => null, (e) => e); expect(err).to.be.an('error'); expect(err.status).to.equal(502); @@ -467,10 +541,10 @@ describe('serenity tag-tree', () => { transport, WS, PROJECT, - [TAG_IDS.sourceHuman, 'no-such-tag'], + [TAG_IDS.originHuman, 'no-such-tag'], fakeLog(), ); - expect(found.get(TAG_IDS.sourceHuman).rootName).to.equal('source'); + expect(found.get(TAG_IDS.originHuman).rootName).to.equal('origin'); expect(found.get('no-such-tag')).to.deep.equal({ kind: 'unknown', parentId: null, rootName: null, ancestorIds: [], }); @@ -505,9 +579,9 @@ describe('serenity tag-tree', () => { it('reports the same bare name under a different root as that other dimension', async () => { const transport = { listProjectTags: makeListProjectTagsStub() }; - const placed = await findTagsInTree(transport, WS, PROJECT, [TAG_IDS.sourceHuman], fakeLog()); - const found = placed.get(TAG_IDS.sourceHuman); - expect(found.rootName).to.equal('source'); + const placed = await findTagsInTree(transport, WS, PROJECT, [TAG_IDS.originHuman], fakeLog()); + const found = placed.get(TAG_IDS.originHuman); + expect(found.rootName).to.equal('origin'); }); it('reports an id absent from the tree as unknown', async () => { @@ -582,7 +656,7 @@ describe('serenity tag-tree', () => { // The cheap check — comparing against the three closed root ids — passes here. // Ancestry is what catches it. const transport = { listProjectTags: makeListProjectTagsStub() }; - const err = await assertParentWithinDimension(transport, WS, PROJECT, 'category', TAG_IDS.sourceHuman, fakeLog()).then(() => null, (e) => e); + const err = await assertParentWithinDimension(transport, WS, PROJECT, 'category', TAG_IDS.originHuman, fakeLog()).then(() => null, (e) => e); expect(err.status).to.equal(400); });