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