-
Notifications
You must be signed in to change notification settings - Fork 16
feat(serenity): server-owned source dimension + canonicalizeSource (LLMO-6282) [WP-S2] #2867
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
39bfbfd
be52698
509227a
d5e7fae
26f88ec
23d1192
3187325
8894587
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 |
|---|---|---|
|
|
@@ -591,10 +591,18 @@ function BrandsController(ctx, log, env) { | |
| // queue consumer, re-ordered middleware) must not silently gain service | ||
| // privilege — hence `!authType → user`, and a non-function `getType` resolves | ||
| // to `undefined` (→ user) rather than throwing. | ||
| // | ||
| // `source` (the producing system) has NO write surface (source-dimension.md | ||
| // §1 item 6): a caller-supplied `source` is ignored, so a v2 create becomes | ||
| // the store's `config` default (item 5, gate §6.4/§6.6). It is dropped here | ||
| // rather than passed to upsertPrompts, whose `source: p.source || 'config'` | ||
| // write-side default stays load-bearing for the internal writers that DO set | ||
| // it. `updatePromptById` likewise never patches source (producer is fixed at | ||
| // creation). | ||
| const { authInfo } = context.attributes ?? {}; | ||
| const authType = typeof authInfo?.getType === 'function' ? authInfo.getType() : undefined; | ||
| const isUserPrincipal = !authType || authType === 'jwt' || authType === 'ims'; | ||
| const derivedPrompts = prompts.map((p) => ({ | ||
| const derivedPrompts = prompts.map(({ source: _, ...p }) => ({ | ||
|
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.
|
||
| ...p, | ||
| origin: deriveV2PromptOrigin(p?.origin, isUserPrincipal), | ||
| })); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ 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 { canonicalizeSource, foldSourceValue } from './serenity/prompt-tags.js'; | ||
|
|
||
| // Re-exported for backward compatibility — `normalizeIntent`/`INTENT_VALUES` now | ||
| // live in `./intent.js` so the LLM intent classifier can reuse them without an | ||
|
|
@@ -427,6 +428,11 @@ const SORT_COLUMN_MAP = { | |
| origin: 'origin', | ||
| status: 'status', | ||
| updatedAt: 'updated_at', | ||
| // Sort on the `source_canonical` generated column (WP-S4: | ||
| // `GENERATED ALWAYS AS (lower(replace(source,'_','-'))) STORED`, btree-indexed), | ||
| // so the two drift spellings of a producer order together and the label — which | ||
| // lives in elmo, never on the server — plays no part (source-dimension.md §3.1). | ||
| source: 'source_canonical', | ||
| }; | ||
|
|
||
| function mapRowToPrompt(row) { | ||
|
|
@@ -455,7 +461,13 @@ function mapRowToPrompt(row) { | |
| // fail-loud choice over a fabricated `human`; unlike `source`/`status`, whose | ||
| // fallbacks are cosmetic, an origin fallback is a correctness hazard. | ||
| origin: row.origin, | ||
| source: row.source || 'config', | ||
| // Second derivation boundary (source-dimension.md §3.1): the v2 read surface | ||
| // returns the CANONICAL slug, so elmo's badge — which keys on the API's value — | ||
| // resolves (`agentic_traffic` → `agentic-traffic`). A value that fails the guard | ||
| // (empty, `:`, over-long, root-shadowing) returns the RAW string rather than | ||
| // null: the grid must still show the operator what is stored. `?? 'config'` only | ||
| // guards a nullish column (in-memory/test rows); the DB column is NOT NULL. | ||
| source: canonicalizeSource(row.source) ?? row.source ?? 'config', | ||
|
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. Second derivation boundary correct: |
||
| intent: row.intent ?? null, | ||
| createdAt: row.created_at, | ||
| createdBy: row.created_by, | ||
|
|
@@ -502,9 +514,11 @@ function mapRowToPrompt(row) { | |
| * topic name, category name | ||
| * @param {string} [params.region] - Filter by region (array containment) | ||
| * @param {string} [params.origin] - Filter by origin (ai, human) | ||
| * @param {string} [params.source] - Filter by source (e.g. gsc, semrush, base_url, config) | ||
| * @param {string} [params.source] - Filter by source, matched on the | ||
| * `source_canonical` generated column: `citation-attempt` also matches rows | ||
| * stored as `citation_attempt` (drift spellings fold together, case-insensitive). | ||
| * @param {string} [params.sort] - Sort column (topic, prompt, category, origin, | ||
| * status, updatedAt) | ||
| * source, status, updatedAt) | ||
| * @param {string} [params.order] - Sort direction (asc, desc). Default desc | ||
| * @param {number} [params.limit] - Page size (default 100, max 5000) | ||
| * @param {number} [params.page] - Page number, 1-based (default 1) | ||
|
|
@@ -633,7 +647,13 @@ export async function listPrompts({ | |
| } | ||
|
|
||
| if (hasText(source)) { | ||
| baseQuery = baseQuery.eq('source', source); | ||
| // Match on the `source_canonical` generated column (WP-S4) — the DB | ||
| // canonicalizes `lower(replace(source,'_','-'))` at write time, so a single | ||
| // equality on the folded incoming value catches every drift spelling AND is | ||
| // case-insensitive (`citation-attempt` finds rows stored `citation_attempt` | ||
| // or `Citation_Attempt`). Fold the query-param value with the same transform | ||
| // (`foldSourceValue` — the single definition) so the two sides align. | ||
| baseQuery = baseQuery.eq('source_canonical', foldSourceValue(source)); | ||
| } | ||
|
|
||
| if (hasText(region)) { | ||
|
|
@@ -1103,6 +1123,9 @@ export async function updatePromptById({ | |
| } | ||
|
|
||
| const patch = { updated_by: updatedBy }; | ||
| // `source` is deliberately NOT patchable (source-dimension.md §1 item 6): a | ||
| // prompt's producer is fixed at creation, and the dimension has no write surface. | ||
| // A caller-supplied `updates.source` is ignored rather than written. | ||
| if (updates.prompt !== undefined) { | ||
| patch.text = updates.prompt; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,8 +41,8 @@ import { withResourceLock } from '../resource-lock.js'; | |
| import { | ||
| modelChangeUnits, releaseAiSurplus, PROJECT_BLOCK, PROMPT_BLOCK, | ||
| } from '../resource-manager.js'; | ||
| import { DIMENSION, STANDARD_PROMPT_TAG_VALUES } from '../prompt-tags.js'; | ||
| import { provisionDimensionTree } from '../tag-tree.js'; | ||
| import { DIMENSION, STANDARD_PROMPT_TAG_VALUES, GENERATED_PROMPT_SOURCE_VALUE } from '../prompt-tags.js'; | ||
| import { provisionDimensionTree, ensureServerOwnedValue } from '../tag-tree.js'; | ||
| import { classifyBrandedTag, needlesFromNames } from '../branded-classifier.js'; | ||
| import { collectBrandUrlEntries, attachBrandUrlsToProject, primaryDomainSet } from '../brand-urls.js'; | ||
| import { resolveProjects } from '../resolve-projects.js'; | ||
|
|
@@ -179,10 +179,10 @@ function validateCreateBody(body) { | |
| * Generates topics + prompts for (domain, country) via the AI-SEO service | ||
| * (transport.getBrandTopics) and attaches them to the project. Keeps the top | ||
| * `topicCap` topics by search volume (0 = keep all) and tags every prompt with | ||
| * the standard closed-dimension values ({@link STANDARD_PROMPT_TAG_VALUES}) plus | ||
| * a branded / non-branded `type` value derived from `brandNames` (brand name + | ||
| * aliases). Returns the topic/prompt counts. A generation that yields nothing is | ||
| * a clean no-op (no upstream write). | ||
| * the standard closed-dimension values ({@link STANDARD_PROMPT_TAG_VALUES}), the | ||
| * producing `source/semrush` value, and a branded / non-branded `type` value | ||
| * derived from `brandNames` (brand name + aliases). Returns the topic/prompt | ||
| * counts. A generation that yields nothing is a clean no-op (no upstream write). | ||
| * | ||
| * The generated topic name is NOT attached. Under the dimension-root model a | ||
| * topic is a sub-category — a depth-3 descendant of a customer category — and | ||
|
|
@@ -263,6 +263,19 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, { | |
| const standardIds = STANDARD_PROMPT_TAG_VALUES.map( | ||
| ({ dimension, name }) => /** @type {string} */ (values.get(dimension)?.get(name)), | ||
| ); | ||
| // Stamp the producing system. This generator builds its prompts from Semrush's | ||
| // own `getBrandTopics`, so every generated prompt is `source/semrush` — the | ||
| // persisted SR-AI-Visibility key, a constant at THIS write site, NOT `config` | ||
| // (source-dimension.md §1 item 2). `source` is open, so the value is resolved-or- | ||
| // created on demand rather than pre-provisioned in `provisioned.values`. | ||
| const { id: sourceId } = await ensureServerOwnedValue( | ||
| transport, | ||
| workspaceId, | ||
| projectId, | ||
| DIMENSION.SOURCE, | ||
| GENERATED_PROMPT_SOURCE_VALUE, | ||
| log, | ||
| ); | ||
| const typeValues = /** @type {Map<string, string>} */ (values.get(DIMENSION.TYPE)); | ||
|
|
||
| /** @type {Map<string, string[]>} bare type value → prompt texts */ | ||
|
|
@@ -288,7 +301,12 @@ 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]), | ||
| () => transport.createPromptsByIds( | ||
|
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. (c) Rebase conflict #1 sound: the S4 |
||
| workspaceId, | ||
| projectId, | ||
| items, | ||
| [...standardIds, sourceId, typeId], | ||
|
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. Merge resolution (conflict). Base wrapped this |
||
| ), | ||
| { callSite: 'createPromptsByIds' }, | ||
| ); | ||
| } | ||
|
|
||
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.
Fixed (bot nit). Dropped
default: "config"from thisreadOnlyV2Prompt.sourcefield — adefaulton a read-only output field is an OpenAPI 3.0 no-op (clients never send it, servers never default output).example: "config"stays for docs.