-
Notifications
You must be signed in to change notification settings - Fork 16
feat(serenity): tolerant origin/source authorship-root resolver; rename source→origin (LLMO-6274) #2862
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(serenity): tolerant origin/source authorship-root resolver; rename source→origin (LLMO-6274) #2862
Changes from all commits
7bbc71d
9e0f3a9
81ca372
97466e7
d17b2bd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,13 @@ | |
| * 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. | ||
| * | ||
| * The authorship root is being renamed `source` → `origin` in place across the | ||
| * live projects (origin-dimension.md). Until that migration's contract phase | ||
| * (WP-O6) lands, the tag-tree resolver tolerates BOTH names — see | ||
| * {@link LEGACY_AUTHORSHIP_ROOT_NAME} and `tag-tree.js`. | ||
| */ | ||
|
|
||
| /** | ||
|
|
@@ -40,20 +45,27 @@ | |
| export const DIMENSION = Object.freeze({ | ||
| CATEGORY: 'category', | ||
| INTENT: 'intent', | ||
| SOURCE: 'source', | ||
| ORIGIN: 'origin', | ||
| TYPE: 'type', | ||
| }); | ||
|
|
||
| /** | ||
| * The pre-rename name of the authorship root. Live projects provisioned before | ||
| * the rename still carry a root named `source` (with `ai` / `human` beneath it); | ||
| * the tolerant resolver accepts it in place of `origin` until WP-O6 drops this. | ||
| */ | ||
| export const LEGACY_AUTHORSHIP_ROOT_NAME = 'source'; | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. New |
||
|
|
||
| /** 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({ | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| AI: 'ai', | ||
| HUMAN: 'human', | ||
| }); | ||
|
|
@@ -98,14 +110,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,30 +132,37 @@ 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` | ||
| * (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 — | ||
| * see the handler), so it is NOT seeded here. | ||
| * The closed-dimension values applied to EVERY AI-generated prompt: the `origin` | ||
| * value `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 — see the handler), so it is NOT seeded here. | ||
| * | ||
| * Each entry names a dimension and the bare value beneath it; the caller resolves | ||
| * 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 }), | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Standard AI-prompt seed now emits |
||
| Object.freeze({ dimension: DIMENSION.INTENT, name: INTENT_VALUE.INFORMATIONAL }), | ||
| ]); | ||
|
|
||
| /** | ||
| * True when `name` is one of the four dimension roots. Root names are reserved: | ||
| * True when `name` is a reserved dimension-root name. Root names are reserved: | ||
| * a customer category may not be called `category`, and a closed value may not | ||
| * be minted at the root level. | ||
| * | ||
| * While the `source` → `origin` rename is in flight, the legacy authorship name | ||
| * ({@link LEGACY_AUTHORSHIP_ROOT_NAME}) is ALSO reserved — a customer must not be | ||
| * able to mint a tag named `source` during the migration window, or it could be | ||
| * mistaken for (or collide with) the legacy authorship root the tolerant resolver | ||
| * still adopts. Dropped with the rest of the fallback at WP-O6. | ||
| * | ||
| * @param {string} name - a bare tag name. | ||
| * @returns {boolean} | ||
| */ | ||
| export function isDimensionRootName(name) { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return (/** @type {readonly string[]} */ (DIMENSION_ROOT_NAMES)).includes(name); | ||
| return name === LEGACY_AUTHORSHIP_ROOT_NAME | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MysticatBot suggestion #3. |
||
| || (/** @type {readonly string[]} */ (DIMENSION_ROOT_NAMES)).includes(name); | ||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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`. | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MysticatBot nit #2. Stale file-header reference |
||
| * - 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 | ||
|
|
@@ -51,6 +51,8 @@ import { | |
| DIMENSION_ROOT_NAMES, | ||
| CLOSED_DIMENSION_VALUES, | ||
| CLOSED_DIMENSIONS, | ||
| ORIGIN_VALUE, | ||
| LEGACY_AUTHORSHIP_ROOT_NAME, | ||
| } from './prompt-tags.js'; | ||
|
|
||
| /** | ||
|
|
@@ -128,6 +130,9 @@ export async function indexLevelByName(transport, semrushWorkspaceId, projectId, | |
| * @param {string} parentId - '' to create at the root level. | ||
| * @param {readonly string[]} wanted - bare names that must exist under `parentId`. | ||
| * @param {object} [log] - logger. | ||
| * @param {Map<string, string>} [preRead] - an already-read `indexLevelByName` of this | ||
| * parent's level, reused instead of reading it again. Lets a caller that has already | ||
| * inspected the level (e.g. the tolerant root resolver) avoid a redundant read. | ||
| * @returns {Promise<{ byName: Map<string, string>, createdNames: string[] }>} | ||
| * `byName` maps every wanted name to its tag id. | ||
| */ | ||
|
|
@@ -138,8 +143,10 @@ export async function ensureChildren( | |
| parentId, | ||
| wanted, | ||
| log, | ||
| preRead, | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. New optional |
||
| ) { | ||
| const existing = await indexLevelByName(transport, semrushWorkspaceId, projectId, parentId, log); | ||
| const existing = preRead | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ?? await indexLevelByName(transport, semrushWorkspaceId, projectId, parentId, log); | ||
| const missing = wanted.filter((name) => !existing.has(name)); | ||
| if (missing.length === 0) { | ||
| return { byName: existing, createdNames: [] }; | ||
|
|
@@ -210,27 +217,94 @@ export async function ensureChildren( | |
| return { byName, createdNames: missing.filter((name) => existing.has(name)) }; | ||
| } | ||
|
|
||
| /** | ||
| * True when a root tag's children are a subset of the authorship vocabulary | ||
| * `{ai, human}`. A childless root passes vacuously (a not-yet-populated authorship | ||
| * root); a `source` root carrying producing-system values (`config`, `gsc`, …) does | ||
| * NOT — that is the companion `source` dimension (source-dimension.md §9), not | ||
| * authorship. This guard is what lets the two names coexist safely during the rename. | ||
| * | ||
| * @param {object} transport | ||
| * @param {string} semrushWorkspaceId | ||
| * @param {string} projectId | ||
| * @param {string} rootId | ||
| * @param {object} [log] | ||
| * @returns {Promise<boolean>} | ||
| */ | ||
| async function childrenAreAuthorship(transport, semrushWorkspaceId, projectId, rootId, log) { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This guard is the crux of the whole tolerance scheme — it's what keeps the #47 producing-system
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| const children = await indexLevelByName(transport, semrushWorkspaceId, projectId, rootId, log); | ||
| const authorship = new Set(/** @type {readonly string[]} */ (Object.values(ORIGIN_VALUE))); | ||
| return [...children.keys()].every((name) => authorship.has(name)); | ||
| } | ||
|
|
||
| /** | ||
| * 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 is resolved TOLERANTLY while the `source` → `origin` rename is | ||
| * in flight (origin-dimension.md): an existing `origin` root, OR a legacy `source` | ||
| * root whose children are a subset of `{ai, human}` ({@link childrenAreAuthorship} — | ||
| * the guard that keeps it from adopting the companion producing-system `source` | ||
| * dimension), satisfies the authorship dimension in place. `origin` is created ONLY | ||
| * when neither exists — a blind create would mint an empty SECOND authorship root the | ||
| * moment code and data disagree (origin-dimension.md §8). Either way the returned | ||
| * map's `origin` key maps to whichever physical root was resolved, so callers key on | ||
| * `DIMENSION.ORIGIN` regardless. Removed with the fallback by WP-O6. | ||
| * | ||
| * @param {object} transport - Serenity transport (Semrush proxy client). | ||
| * @param {string} semrushWorkspaceId | ||
| * @param {string} projectId | ||
| * @param {object} [log] - logger. | ||
| * @returns {Promise<Map<string, string>>} root name → tag id, for all four roots. | ||
| * @returns {Promise<Map<string, string>>} root name → tag id, in root order, with the | ||
| * `origin` key carrying the resolved authorship root's id. | ||
| */ | ||
| export async function ensureDimensionRoots(transport, semrushWorkspaceId, projectId, log) { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| const existing = await indexLevelByName(transport, semrushWorkspaceId, projectId, '', log); | ||
|
|
||
| // Tolerant authorship resolution: prefer `origin`; else adopt a legacy `source` root | ||
| // in place (guarded so the companion producing-system `source` dimension is never | ||
| // mistaken for authorship). | ||
| let authorshipId = existing.get(DIMENSION.ORIGIN); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tolerant resolution order is right: prefer a real |
||
| if (!authorshipId) { | ||
| const legacyId = existing.get(LEGACY_AUTHORSHIP_ROOT_NAME); | ||
| if (legacyId | ||
| && await childrenAreAuthorship(transport, semrushWorkspaceId, projectId, legacyId, log)) { | ||
| log?.info?.('ensureDimensionRoots: adopting the legacy `source` authorship root in place', { | ||
| semrushWorkspaceId, projectId, rootId: legacyId, | ||
| }); | ||
| authorshipId = legacyId; | ||
| } | ||
| } | ||
|
|
||
| // Resolve-or-create every root except `origin`, and `origin` too UNLESS an authorship | ||
| // root was already found — creating it only then keeps the fresh-project path a single | ||
| // create call while never minting a second authorship root on a mid-rename project. | ||
| const wanted = DIMENSION_ROOT_NAMES.filter( | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| (name) => name !== DIMENSION.ORIGIN || !authorshipId, | ||
| ); | ||
| // Reuse the root-level read above — the tolerant resolve costs no extra read on the | ||
| // common path (only `childrenAreAuthorship` adds one, and only when a legacy `source` | ||
| // root is present). | ||
| const { byName } = await ensureChildren( | ||
| transport, | ||
| semrushWorkspaceId, | ||
| projectId, | ||
| '', | ||
| DIMENSION_ROOT_NAMES, | ||
| wanted, | ||
| log, | ||
| existing, | ||
| ); | ||
| return byName; | ||
|
|
||
| // Return the roots in canonical order, with `origin` carrying the resolved id. | ||
| const roots = new Map(); | ||
| for (const name of DIMENSION_ROOT_NAMES) { | ||
| roots.set( | ||
| name, | ||
| name === DIMENSION.ORIGIN ? (authorshipId ?? byName.get(name)) : byName.get(name), | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Return-map assembly: iterating |
||
| ); | ||
| } | ||
| return roots; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -465,7 +539,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`). | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MysticatBot nit #1. Stale |
||
| * @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` | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -320,7 +320,7 @@ export default function serenityTests( | |
| ); | ||
| expect(roots.status).to.equal(200); | ||
| expect(roots.body.items.map((t) => t.name)) | ||
| .to.have.members(['category', 'intent', 'source', 'type']); | ||
| .to.have.members(['category', 'intent', 'origin', 'type']); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CI fix (it-postgres failure #1). Root-list assertion renamed |
||
| const categoryRoot = roots.body.items.find((t) => t.name === 'category'); | ||
| expect(res.body.parentId).to.equal(categoryRoot.id); | ||
| }); | ||
|
|
@@ -475,20 +475,20 @@ export default function serenityTests( | |
| expect(res.status).to.equal(400); | ||
| }); | ||
|
|
||
| it('POST /serenity/tags resolves a closed-dimension tag idempotently (source/intent/type)', async () => { | ||
| it('POST /serenity/tags resolves a closed-dimension tag idempotently (origin/intent/type)', async () => { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CI fix (it-postgres failure #2). This case POSTed |
||
| await createUsMarket(); | ||
| const first = await getHttpClient().admin.post(`${base}/tags`, { | ||
| type: 'source', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', | ||
| type: 'origin', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', | ||
| }); | ||
| expect(first.status).to.equal(200); | ||
| expect(first.body).to.include({ type: 'source', name: 'ai' }); | ||
| expect(first.body).to.include({ type: 'origin', name: 'ai' }); | ||
| expect(first.body.id).to.be.a('string').that.is.not.empty; | ||
| // The value hangs under the `source` root, never at the root level. | ||
| // The value hangs under the `origin` root, never at the root level. | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comment updated to |
||
| expect(first.body.parentId).to.be.a('string').that.is.not.empty; | ||
|
|
||
| // Same closed-dimension value again — resolved, not re-created (no upstream collision). | ||
| const second = await getHttpClient().admin.post(`${base}/tags`, { | ||
| type: 'source', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', | ||
| type: 'origin', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', | ||
| }); | ||
| expect(second.status).to.equal(200); | ||
| expect(second.body).to.include({ name: 'ai', id: first.body.id, created: false }); | ||
|
|
@@ -500,7 +500,7 @@ export default function serenityTests( | |
| it('PATCH /serenity/tags/:tagId 400s a rename of a closed-dimension value', async () => { | ||
| await createUsMarket(); | ||
| const created = await getHttpClient().admin.post(`${base}/tags`, { | ||
| type: 'source', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', | ||
| type: 'origin', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', | ||
| }); | ||
| expect(created.status).to.equal(200); | ||
| const res = await getHttpClient().admin.patch(`${base}/tags/${created.body.id}`, { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Authorship dimension root renamed
source→origin. This is the canonical name the rest of the program (WP-O2a…O6) keys on; the legacy physicalsourceroot is tolerated at the resolver layer, not here.