Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/openapi/prompts-v2-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: [ ]
Expand Down
5 changes: 5 additions & 0 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/controllers/brands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -567,6 +572,7 @@ function BrandsController(ctx, log, env) {
postgrestClient,
updatedBy,
classifyIntent: classifyIntent ?? undefined,
isServicePrincipal,
});

return createResponse({ created, updated, prompts: outPrompts }, 201);
Expand Down
45 changes: 39 additions & 6 deletions src/support/prompts-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,33 @@ import { classifyIntents } from './intent-classifier.js';
import { throwOnPgConstraintViolation } from './errors.js';
import { assertPermittedSource } from './prompt-sources.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
Expand Down Expand Up @@ -406,7 +433,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,
Expand Down Expand Up @@ -723,6 +752,9 @@ function buildPromptKey({ text, regions, source }) {
* 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({
Expand All @@ -733,6 +765,7 @@ export async function upsertPrompts({
updatedBy = 'system',
classifyIntent,
classifyIntentBatchTimeoutMs = 8000,
isServicePrincipal = false,
}) {
if (!postgrestClient?.from) {
throw new Error('PostgREST client is required for prompts');
Expand Down Expand Up @@ -825,8 +858,8 @@ export async function upsertPrompts({
category_id: categoryUuid,
topic_id: topicUuid,
status: p.status || 'active',
origin: p.origin || 'human',
source,
origin: resolveOriginForWrite(p.origin, isServicePrincipal),
source: p.source || 'config',
intent: normalizeIntent(p.intent),
updated_by: updatedBy,
};
Expand Down Expand Up @@ -1050,9 +1083,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);
Expand Down
2 changes: 1 addition & 1 deletion src/support/serenity/handlers/prompts-subworkspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
130 changes: 93 additions & 37 deletions src/support/serenity/handlers/prompts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, Promise<{ computedId: string, typeTagIds: string[] }>>} */
const cache = new Map();
return async function injectComputedType(projectId, input) {
if (typeof classifyPromptType !== 'function') {
return input;
const typeCache = new Map();
/** @type {Map<string, Promise<{ computedId: string, originTagIds: string[] }>>} */
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;
};
}

Expand Down Expand Up @@ -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]);
Expand Down
27 changes: 17 additions & 10 deletions src/support/serenity/prompt-tags.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*/

/**
Expand All @@ -40,20 +47,20 @@
export const DIMENSION = Object.freeze({
CATEGORY: 'category',
INTENT: 'intent',
SOURCE: 'source',
ORIGIN: 'origin',
TYPE: 'type',
});

/** Root names, in the order they are provisioned on a project. */
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',
});
Expand Down Expand Up @@ -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,
]);

Expand All @@ -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 —
Expand All @@ -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 }),
]);

Expand Down
Loading
Loading