-
Notifications
You must be signed in to change notification settings - Fork 16
feat(serenity): stamp prompt authorship metadata + DTO + sort/order (LLMO-6289) #2877
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
base: main
Are you sure you want to change the base?
Changes from all commits
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,6 +19,7 @@ import { | |
| ERROR_CODES, isUpstreamGone, isMeteredQuota, toQuotaExceededError, | ||
| } from '../errors.js'; | ||
| import { normalizeGeoTargetId, normalizeLanguageCode } from '../validation.js'; | ||
| import { buildCreateMetadata } from './prompts.js'; | ||
| import { | ||
| resolveLocation, | ||
| resolveLanguageId, | ||
|
|
@@ -190,7 +191,7 @@ function validateCreateBody(body) { | |
| * there is no correct parent to create them below. Generated prompts therefore | ||
| * arrive uncategorized and are categorized later (adobe/serenity-docs#44). | ||
| * | ||
| * Writes are id-based: `createPromptsByIds` takes ONE shared `tag_ids` array per | ||
| * Writes are id-based: `createPromptsWithMetadata` takes ONE shared `tag_ids` array per | ||
| * call, so the texts are partitioned by their resolved tag-id set — which, with | ||
| * topics gone, is exactly two groups (branded and non-branded). Identical text | ||
| * collapses to one entry per group. | ||
|
|
@@ -214,12 +215,14 @@ function validateCreateBody(body) { | |
| * REQUIRED, not optional: a genuine no-op object when the flag is OFF, never `undefined`. Not | ||
| * optional-chained at the call site below (Rainer review) — a caller that forgets to thread it | ||
| * must fail loud, not silently skip metering. PROMPT metering seam (Rainer, live-verified | ||
| * LLMO-6190): the metered write is `createPromptsByIds` below, NOT publish — front it BEFORE the | ||
| * write loop, sized on the real prompt count now that it's known (`texts.size`), not an estimate. | ||
| * LLMO-6190): the metered write is `createPromptsWithMetadata` below, NOT publish — front it | ||
| * BEFORE the write loop, sized on the real prompt count now known (`texts.size`), not estimated. | ||
| * @param {string} callerId - resolved caller id (see `resolveCallerId`) stamped as | ||
| * `created_by`/`updated_by` on every generated prompt (LLMO-6289). | ||
| */ | ||
| async function generateAndAttachPrompts(transport, workspaceId, projectId, { | ||
| domain, country, topicCap = 0, brandNames = [], provisioned, | ||
| }, log, headroom) { | ||
| }, log, headroom, callerId) { | ||
| const raw = await transport.getBrandTopics(workspaceId, { domain, country }); | ||
| let topics = []; | ||
| if (Array.isArray(raw)) { | ||
|
|
@@ -255,7 +258,7 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, { | |
| return { topicCount: 0, promptCount: 0 }; | ||
| } | ||
|
|
||
| // Resolve every tag id we are about to attach. `createPromptsByIds` is ATOMIC on | ||
| // Resolve every tag id we are about to attach. `createPromptsWithMetadata` is ATOMIC on | ||
| // an unresolvable id (live 500s and creates nothing), so ids are never guessed. | ||
| // `provisionDimensionTree` resolved every closed value or threw a 502, so the | ||
| // standard values and the whole `type` vocabulary are present here by construction. | ||
|
|
@@ -279,6 +282,10 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, { | |
|
|
||
| await headroom.ensure({ prompts: texts.size }, { includeDrafted: true }); | ||
|
|
||
| // STAMP (LLMO-6289): AI-generated prompts are created through the v3 | ||
| // metadata-carrying write, `created_* = updated_* = now / callerId`. One | ||
| // metadata object per batch (same instant for every text in the group). | ||
| const metadata = buildCreateMetadata(callerId); | ||
| for (const [value, items] of byTypeValue) { | ||
| // `branded` / `non-branded` are the classifier's only outputs and both are in | ||
| // the `type` vocabulary provisioned above. | ||
|
|
@@ -288,8 +295,13 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, { | |
| // top-up) — route through `headroom.retryOnQuota` (no-op passthrough when the flag is OFF). | ||
| // eslint-disable-next-line no-await-in-loop | ||
| await headroom.retryOnQuota( | ||
| () => transport.createPromptsByIds(workspaceId, projectId, items, [...standardIds, typeId]), | ||
| { callSite: 'createPromptsByIds' }, | ||
| () => transport.createPromptsWithMetadata( | ||
|
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. Rebase-conflict resolution confirmed sound: WP1's v3 metadata create is nested inside the upstream |
||
| workspaceId, | ||
| projectId, | ||
| items.map((name) => ({ name, metadata })), | ||
| [...standardIds, typeId], | ||
| ), | ||
| { callSite: 'createPromptsWithMetadata' }, | ||
| ); | ||
| } | ||
| return { topicCount: selected.length, promptCount: texts.size }; | ||
|
|
@@ -357,6 +369,10 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, { | |
| * `ensureSubworkspace` is skipped. When false, byte-for-byte the pre-this-PR behavior. | ||
| * (The units pool for JIT sizing is the positional `parentWorkspaceId` arg — the same id given | ||
| * to `ensureSubworkspace` — so it is not duplicated in this options bag.) | ||
| * @param {string} [options.callerId='unknown'] - resolved caller id (see | ||
| * `resolveCallerId`) stamped as `created_by`/`updated_by` on any AI-generated | ||
| * prompt this create attaches (LLMO-6289). Defaults to the `unknown` sentinel | ||
| * so a caller that omits it never writes an empty author. | ||
| */ | ||
| export async function handleCreateMarketSubworkspace( | ||
| transport, | ||
|
|
@@ -376,6 +392,7 @@ export async function handleCreateMarketSubworkspace( | |
| publishMode = 'require', | ||
| dataAccess = null, | ||
| dynamicAllocation = false, | ||
| callerId = 'unknown', | ||
| } = {}, | ||
| ) { | ||
| const errors = validateCreateBody(body); | ||
|
|
@@ -508,6 +525,7 @@ export async function handleCreateMarketSubworkspace( | |
| }, | ||
| log, | ||
| headroom, | ||
| callerId, | ||
| ); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,8 @@ import { | |
| parseUpdatePromptBody, | ||
| mapLimit, | ||
| publishAffected, | ||
| resolveSort, | ||
| buildUpdateMetadata, | ||
| DEFAULT_PAGE_LIMIT, | ||
| MAX_PAGE_LIMIT, | ||
| MAX_TAG_IDS, | ||
|
|
@@ -79,6 +81,9 @@ export async function handleListPromptsSubworkspace(transport, workspaceId, quer | |
| const tagIds = Array.isArray(query?.tagIds) | ||
| ? query.tagIds.slice(0, MAX_TAG_IDS).map(String).filter(Boolean) | ||
| : []; | ||
| // sort/order (LLMO-6289): validated against the metadata allow-list and | ||
| // forwarded upstream — kept in lockstep with the flat-mode twin. | ||
| const { sort, order } = resolveSort(query); | ||
|
|
||
| const project = await resolveProject(transport, workspaceId, geoTargetId, languageCode, log); | ||
| if (!project) { | ||
|
|
@@ -97,6 +102,8 @@ export async function handleListPromptsSubworkspace(transport, workspaceId, quer | |
| page, | ||
| limit, | ||
| search, | ||
| sort, | ||
| order, | ||
| }); | ||
| const items = Array.isArray(resp?.items) ? resp.items : []; | ||
| let total; | ||
|
|
@@ -126,6 +133,7 @@ export async function handleCreatePromptsSubworkspace( | |
| body, | ||
| log, | ||
| classifyPromptType, | ||
| callerId, | ||
| { dynamicAllocation = false, parentWorkspaceId = '' } = {}, | ||
| ) { | ||
| const inputs = Array.isArray(body?.prompts) ? body.prompts : []; | ||
|
|
@@ -143,7 +151,7 @@ export async function handleCreatePromptsSubworkspace( | |
| const injectComputedType = makeTypeInjector(transport, workspaceId, classifyPromptType, log); | ||
|
|
||
| // PROMPT metering seam (Rainer, live-verified LLMO-6190): the metered write is | ||
| // `createPromptsByIds` (inside `createOnePrompt` below), NOT publish — a disguised-quota 405 | ||
| // `createPromptsWithMetadata` (inside `createOnePrompt` below), NOT publish — a disguised 405 | ||
| // fires there, before any publish, if `used + drafted + batch > total`. Front headroom BEFORE | ||
| // this loop, sized on the whole incoming batch (`inputs.length` — a safe upper bound; some | ||
| // inputs may still skip on validation/missing-project, so this can over-provision slightly, never | ||
|
|
@@ -184,7 +192,7 @@ export async function handleCreatePromptsSubworkspace( | |
| // no-op passthrough when the flag is OFF) so each item recovers independently; `mapLimit`'s | ||
| // own per-item try/catch below still isolates a surviving failure to this one item. | ||
| const semrushPromptId = await headroom.retryOnQuota( | ||
| () => createOnePrompt(transport, workspaceId, projectId, typed), | ||
| () => createOnePrompt(transport, workspaceId, projectId, typed, callerId), | ||
|
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 subworkspace twin: |
||
| { callSite: 'createOnePrompt' }, | ||
| ); | ||
| return { | ||
|
|
@@ -254,10 +262,12 @@ export async function handleCreatePromptsSubworkspace( | |
| * PATCH /serenity/prompts/:semrushPromptId (subworkspace) — in-place edit. | ||
| * Resolves the slice's project from the live listing, then edits the prompt IN | ||
| * PLACE exactly like the flat-mode twin (see handleUpdatePrompt's contract): | ||
| * `rename` first (the one op that can refuse — upstream 404 → promptNotFound, | ||
| * 409 text collision → thrown for the controller's `conflict` mapping), then | ||
| * the replace-mode batch tag write. The prompt id is preserved end to end and | ||
| * echoed unchanged in the response; nothing is deleted on this path. | ||
| * the combined v3 `PATCH .../{id}` first (text `name` + `updated_*` metadata | ||
| * stamp in one request — the one op that can refuse: upstream 404 → | ||
| * promptNotFound, 409 text collision → thrown for the controller's `conflict` | ||
| * mapping), then the replace-mode batch tag write (v2, metadata-free). The | ||
| * prompt id is preserved end to end and echoed unchanged in the response; | ||
| * nothing is deleted on this path. | ||
| */ | ||
| export async function handleUpdatePromptSubworkspace( | ||
| transport, | ||
|
|
@@ -266,6 +276,7 @@ export async function handleUpdatePromptSubworkspace( | |
| body, | ||
| log, | ||
| classifyPromptType, | ||
| callerId, | ||
| ) { | ||
| const parsedBody = parseUpdatePromptBody(body); | ||
| if (!parsedBody.ok) { | ||
|
|
@@ -305,7 +316,13 @@ export async function handleUpdatePromptSubworkspace( | |
| }); | ||
|
|
||
| try { | ||
| await transport.renamePrompt(workspaceId, projectId, semrushPromptId, nextText); | ||
| // Combined v3 write: text (`name`) + `updated_*` stamp in one request | ||
| // (replaces the v2 `rename`). Same refusal contract — 404 → promptNotFound, | ||
| // 409 (sibling text collision) → thrown for the controller's `conflict`. | ||
| await transport.patchPrompt(workspaceId, projectId, semrushPromptId, { | ||
| name: nextText, | ||
| metadata: buildUpdateMetadata(callerId), | ||
| }); | ||
| } catch (e) { | ||
| if (isUpstreamGone(e)) { | ||
| return { | ||
|
|
@@ -334,7 +351,7 @@ export async function handleUpdatePromptSubworkspace( | |
| // The rename above already landed: the prompt's text has moved while its | ||
| // tags are stale. Record the partial mutation before propagating, so the | ||
| // generic upstream error the caller sees is attributable on-call. | ||
| log?.warn?.('updatePromptTagsByIds failed after a successful rename — text updated, tags stale', { | ||
| log?.warn?.('updatePromptTagsByIds failed after a successful text/metadata PATCH — text updated, tags stale', { | ||
| semrushPromptId, projectId, error: e.message, | ||
| }); | ||
| throw e; | ||
|
|
||
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.
resolveCallerId(ctx)resolved ONCE for the whole activate batch here and threaded into the per-market loop — correctly NOT re-resolved per market. Same once-per-request discipline as the POST/PATCH handlers. Good.