Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c312e15
feat(serenity): server-compute intent:<Value> on every prompt write path
byteclimber Jul 9, 2026
e852b82
feat(serenity): add deferPublish for CSV chunking on the bulk-create …
byteclimber Jul 9, 2026
634896c
test(serenity): fix IT tagIds-length assertion; add intent-classifica…
byteclimber Jul 9, 2026
7dc3311
fix(serenity): thread writeDeadline from request entry; validate defe…
byteclimber Jul 11, 2026
0b5cd7e
feat(serenity): add intent-classification observability (serenity-doc…
byteclimber Jul 14, 2026
0b0c0ce
test(serenity): assert soft-failure log carries no prompt text
byteclimber Jul 14, 2026
f3f9bd7
Merge branch 'main' into feat/serenity-intent-classification
byteclimber Jul 14, 2026
7598d22
Merge branch 'main' into feat/serenity-intent-classification
byteclimber Jul 20, 2026
a020f7b
fix(serenity): reject empty edit text and modernize edit-path tests
byteclimber Jul 21, 2026
279f99d
fix(serenity): finish LLMO-6190 headroom wiring (type-check + metering)
byteclimber Jul 22, 2026
5596202
fix(serenity): wire create-prompts publish through headroom.retryOnQuota
byteclimber Jul 22, 2026
683f7b9
Merge origin/main into feat/serenity-intent-classification
byteclimber Jul 22, 2026
f161601
refactor(serenity): resolveIntentValueInjection as thin wrapper over …
byteclimber Jul 22, 2026
b8f0ec0
test(serenity): intent strip-by-id collision + DRS system-prompt pari…
byteclimber Jul 22, 2026
4442c57
fix: drop accidentally committed node_modules symlink
byteclimber Jul 22, 2026
02f4ec6
docs+feat(serenity): document deferPublish/published; log AI-gen clas…
byteclimber Jul 22, 2026
a4c937f
test(serenity): AI-gen cap boundary + deferPublish partial-batch cove…
byteclimber Jul 22, 2026
ad26ff3
test(serenity): add published to createSerenityPrompts contract fixture
byteclimber Jul 22, 2026
cd6859d
Merge branch 'main' into feat/serenity-intent-classification
byteclimber Jul 22, 2026
60eb337
fix(serenity): classify trimmed text on create paths + address review
byteclimber Jul 22, 2026
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
coverage
.nyc_output/
node_modules/
node_modules
junit
dist
tmp
Expand Down
19 changes: 18 additions & 1 deletion docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11123,10 +11123,20 @@ SerenityCreatePromptsRequest:
minItems: 1
maxItems: 500
items: { $ref: '#/SerenityPromptInput' }
deferPublish:
type: boolean
default: false
description: >
When true, the created prompts are written to the upstream draft layer
but the project is NOT published as part of this request (the response
carries `published: false`). Used for chunked CSV imports, where the
client posts several chunks and issues a single publish after the last
one (serenity-docs#32). Omitted/false publishes once at the end of the
call as usual.

SerenityCreatePromptsResponse:
type: object
required: [created, skipped, failed]
required: [created, skipped, failed, published]
properties:
created:
type: array
Expand All @@ -11150,6 +11160,13 @@ SerenityCreatePromptsResponse:
languageCode: { type: string }
status: { type: integer }
message: { type: string }
published:
type: boolean
description: >
Whether the project was published as part of this request. Always true
unless the request set `deferPublish: true`, in which case the prompts
were written as drafts and the caller must publish separately
(serenity-docs#32).

SerenityUpdatePromptRequest:
type: object
Expand Down
3 changes: 2 additions & 1 deletion docs/serenity.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ api-service exposes nine endpoints that front the Adobe-hosted Semrush AIO API a

## Environment configuration

The proxy is configured via a single env var that is **required at runtime** — Lambda fails at first request if it is unset.
`SEMRUSH_PROJECTS_BASE_URL` is **required at runtime** — Lambda fails at first request if it is unset. The server-side intent classifier (serenity-docs#32) adds one optional override.

| Variable | Required | Source | Purpose |
|---|---|---|---|
| `SEMRUSH_PROJECTS_BASE_URL` | yes (no source default) | Vault `dx_mysticat/<env>/api-service`; locally `.env` | Upstream host for the Semrush AIO REST API. Must be `https://…`. Trailing slashes are stripped. Per-environment value so the production target can differ from the hackathon host without a code change. |
| `PROMPT_INTENT_CLASSIFICATION_DEPLOYMENT_NAME` | no (falls back to `AZURE_OPEN_AI_API_DEPLOYMENT_NAME`) | Vault `dx_mysticat/<env>/api-service`; locally `.env` | Classifier-scoped Azure OpenAI deployment (model) name for server-side prompt-intent classification (serenity-docs#32). Takes precedence over the shared `AZURE_OPEN_AI_API_DEPLOYMENT_NAME` other Azure consumers use (e.g. `org-detector`), so intent classification can target a different model without affecting them. Unset ⇒ shared deployment; behavior unchanged until explicitly configured. |

### Vault writes (dev / stage / prod)

Expand Down
8 changes: 8 additions & 0 deletions src/controllers/brands.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
import { listViewableResourceIds } from '../support/state-access-mapping-utils.js';
import { isFacsRebacResource } from '../routes/facs-capabilities.js';
import { provisionBrandSubworkspace, provisionBrandSubworkspaceBare, releaseProvisionedWorkspace } from '../support/serenity/brand-provisioning.js';
import { computeWriteDeadline } from '../support/serenity/intent-classification.js';
import { ensureMarketSite } from '../support/serenity/site-linkage.js';
import { upsertMappingRow, linkSiteToLiveRows } from '../support/serenity/mapping-rows.js';
import { createSerenityTransport } from '../support/serenity/rest-transport.js';
Expand Down Expand Up @@ -1484,6 +1485,12 @@ function BrandsController(ctx, log, env) {
const { spaceCatId } = context.params || {};
const brandData = context.data;

// One shared write-budget deadline for the whole request, computed at the
// true request entry (before auth/loadBrand/provisioning) so intent
// classification during provisioning budgets against the real request start
// rather than function-entry time deep in the call stack (serenity-docs#32).
const writeDeadline = computeWriteDeadline();

// Hoisted above the try so the catch can run compensation: if a Semrush
// sub-workspace was provisioned but the brand row failed to persist, the
// catch releases the orphaned allocation (see below).
Expand Down Expand Up @@ -1673,6 +1680,7 @@ function BrandsController(ctx, log, env) {
// market's CI competitor list. Like URLs, they come from the create
// payload (the brand row isn't written yet).
competitors: brandData.competitors,
writeDeadline,
}, log);
provisionedWorkspaceId = provisioned.semrushSubWorkspaceId;
provisionedInitialMarket = {
Expand Down
26 changes: 26 additions & 0 deletions src/controllers/serenity.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { isDynamicAllocationEnabled } from '../support/serenity/dynamic-allocati
import { MAX_TOPICS_ON_CREATE } from '../support/serenity/brand-provisioning.js';
import { marketForGeoTargetId } from '../support/serenity/locations.js';
import { brandNeedles, classifyBrandedTag } from '../support/serenity/branded-classifier.js';
import { computeWriteDeadline } from '../support/serenity/intent-classification.js';
import AccessControlUtil from '../support/access-control-util.js';
import { resolveBrandUuid } from '../support/prompts-storage.js';
import {
Expand Down Expand Up @@ -521,13 +522,18 @@ function SerenityController(context, log, env) {
}
const transport = buildTransport(ctx, imsToken);
const classifyPromptType = await buildPromptTypeClassifier(ctx, auth.brandUuid);
// serenity-docs#32: one shared write-budget deadline for classify + create
// + publish, computed once at request entry.
const writeDeadline = computeWriteDeadline();
const result = auth.mode === 'subworkspace'
? await handleCreatePromptsSubworkspace(
transport,
auth.workspaceId,
ctx.data || {},
log,
classifyPromptType,
ctx.env,
writeDeadline,
{
dynamicAllocation: dynamicAllocationEnabled(ctx),
parentWorkspaceId: auth.parentWorkspaceId ?? '',
Expand All @@ -541,6 +547,8 @@ function SerenityController(context, log, env) {
ctx.data || {},
log,
classifyPromptType,
ctx.env,
writeDeadline,
);
return createResponse(result, 200);
} catch (e) {
Expand All @@ -561,6 +569,7 @@ function SerenityController(context, log, env) {
}
const transport = buildTransport(ctx, imsToken);
const classifyPromptType = await buildPromptTypeClassifier(ctx, auth.brandUuid);
const writeDeadline = computeWriteDeadline();
const result = auth.mode === 'subworkspace'
? await handleUpdatePromptSubworkspace(
transport,
Expand All @@ -569,6 +578,8 @@ function SerenityController(context, log, env) {
ctx.data || {},
log,
classifyPromptType,
ctx.env,
writeDeadline,
)
: await handleUpdatePrompt(
transport,
Expand All @@ -579,6 +590,8 @@ function SerenityController(context, log, env) {
ctx.data || {},
log,
classifyPromptType,
ctx.env,
writeDeadline,
);
return createResponse(result.body, result.status);
} catch (e) {
Expand Down Expand Up @@ -680,6 +693,10 @@ function SerenityController(context, log, env) {

const createMarket = async (ctx) => {
try {
// Shared write-budget deadline, computed once at request entry so intent
// classification during topic/prompt generation budgets against the true
// request start (serenity-docs#32).
const writeDeadline = computeWriteDeadline();
const imsToken = await resolveSemrushImsToken(ctx);
const auth = await authorize(ctx);
if (auth.error) {
Expand Down Expand Up @@ -743,6 +760,8 @@ function SerenityController(context, log, env) {
brandAliases,
brandUrlSources,
competitors,
env: ctx.env,
writeDeadline,
// auth.brandUuid is an already-persisted brand row here (loadBrand
// above), so the mapping-row upsert's FK to brands is satisfied —
// see mapping-rows.js upsertMappingRow doc.
Expand Down Expand Up @@ -1124,6 +1143,11 @@ function SerenityController(context, log, env) {
*/
const activate = async (ctx) => {
try {
// Shared write-budget deadline, computed once at request entry so intent
// classification during per-market topic/prompt generation budgets against
// the true request start rather than per-market function entry
// (serenity-docs#32).
const writeDeadline = computeWriteDeadline();
const imsToken = await resolveSemrushImsToken(ctx);
const auth = await authorize(ctx);
if (auth.error) {
Expand Down Expand Up @@ -1369,6 +1393,8 @@ function SerenityController(context, log, env) {
brandAliases,
brandUrlSources,
competitors,
env: ctx.env,
writeDeadline,
// `brand` was loaded via loadBrand above — an already-persisted
// row, so the mapping-row upsert's FK to brands is satisfied.
// Narrowed to the one model the mapping-row helpers touch — see
Expand Down
72 changes: 58 additions & 14 deletions src/support/intent-classifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,14 @@ export function contentToString(content) {

/**
* Extracts a JSON object from a model response that may include code fences or
* surrounding prose, then returns its normalized `intent` (or null).
* surrounding prose, then returns the parsed object (or null on any parse
* failure). Shared by every category spec — none of this extraction logic is
* taxonomy-specific.
*
* @param {string} content - Raw model output
* @returns {string|null}
* @returns {object|null}
*/
function parseIntent(content) {
function parseModelJson(content) {
if (!hasText(content)) {
return null;
}
Expand All @@ -208,20 +210,58 @@ function parseIntent(content) {
[raw] = brace;
}
try {
const parsed = JSON.parse(raw);
// normalizeIntent lowercases, applies the legacy remap, and validates
// against the 6 canonical buckets (else null) — single source of truth.
return normalizeIntent(parsed?.intent);
return JSON.parse(raw);
} catch {
return null;
}
}

/**
* Creates an intent classifier bound to the Azure OpenAI credentials in `env`.
* The native/DRS 6-bucket category spec — the classifier's default
* configuration, reproducing today's behavior byte-for-byte (it reuses the
* existing {@link SYSTEM_PROMPT} export as-is, so there is nothing new to keep
* in sync). `parseResult` discards `confidence`, matching the pre-existing
* `parseIntent` behavior.
*
* @typedef {object} CategorySpec
* @property {string} systemPrompt - the system prompt for this taxonomy.
* @property {(parsed: object) => (string|null)} parseResult - validates +
* normalizes the parsed JSON body into a canonical value, or null.
* @property {number} [invokeTimeoutMs] - per-call timeout override; falls back
* to {@link resolveInvokeTimeoutMs} (env-driven) when omitted.
*/

/** @type {CategorySpec} */
export const DRS_CATEGORY_SPEC = Object.freeze({
systemPrompt: SYSTEM_PROMPT,
// normalizeIntent lowercases, applies the legacy remap, and validates
// against the 6 canonical buckets (else null) — single source of truth.
parseResult: (parsed) => normalizeIntent(parsed?.intent),
});

/**
* Resolves the Azure OpenAI deployment (model) name from env, allowing a
* classifier-scoped override (`PROMPT_INTENT_CLASSIFICATION_DEPLOYMENT_NAME`)
* to take precedence over the shared per-env deployment used by other Azure
* OpenAI consumers (e.g. `org-detector`). Falls back to the shared deployment
* when the override is unset, so existing behavior is unchanged until the
* override is explicitly configured.
*
* @param {object} env - Environment variables
* @returns {string|undefined}
*/
function resolveDeploymentName(env = {}) {
return env.PROMPT_INTENT_CLASSIFICATION_DEPLOYMENT_NAME
|| env.AZURE_OPEN_AI_API_DEPLOYMENT_NAME;
}

/**
* Creates an intent classifier bound to the Azure OpenAI credentials in `env`,
* for the given taxonomy (`categorySpec`). Defaults to {@link DRS_CATEGORY_SPEC}
* so existing single-arg callers are unaffected.
*
* Returns a function `classify(text) => Promise<string|null>` that is always
* best-effort: it resolves to a canonical bucket on success, or `null` on any
* best-effort: it resolves to a canonical value on success, or `null` on any
* failure or when text/credentials are missing. It NEVER rejects.
*
* Classification runs whenever Azure OpenAI is configured. When the Azure
Expand All @@ -231,17 +271,19 @@ function parseIntent(content) {
* @param {object} context - Helix universal context
* @param {object} context.env - Environment variables (Azure OpenAI creds)
* @param {object} [context.log] - Logger
* @param {CategorySpec} [categorySpec] - the taxonomy to classify into;
* defaults to the native 6-bucket DRS taxonomy.
* @returns {((text: string) => Promise<string|null>)|null}
*/
export function createIntentClassifier(context = {}) {
export function createIntentClassifier(context = {}, categorySpec = DRS_CATEGORY_SPEC) {
const { env = {}, log = console } = context;

const {
AZURE_OPEN_AI_API_KEY: azureOpenAIApiKey,
AZURE_OPEN_AI_API_INSTANCE_NAME: azureOpenAIApiInstanceName,
AZURE_OPEN_AI_API_DEPLOYMENT_NAME: azureOpenAIApiDeploymentName,
AZURE_OPEN_AI_API_VERSION: azureOpenAIApiVersion,
} = env;
const azureOpenAIApiDeploymentName = resolveDeploymentName(env);

if (!hasText(azureOpenAIApiKey)
|| !hasText(azureOpenAIApiInstanceName)
Expand All @@ -266,7 +308,8 @@ export function createIntentClassifier(context = {}) {
return null;
}

const invokeTimeoutMs = resolveInvokeTimeoutMs(env);
const invokeTimeoutMs = categorySpec.invokeTimeoutMs ?? resolveInvokeTimeoutMs(env);
const { systemPrompt, parseResult } = categorySpec;

return async function classify(text) {
// Mirror DRS: skip empty / whitespace-only text (no LLM call, intent null).
Expand All @@ -279,14 +322,15 @@ export function createIntentClassifier(context = {}) {
// On timeout this rejects and falls through to the non-fatal catch below.
const response = await withTimeout(
model.invoke([
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'system', content: systemPrompt },
{ role: 'user', content: trimmed.slice(0, MAX_PROMPT_CHARS) },
]),
invokeTimeoutMs,
);
// content may be a string OR an array of content parts; coerce either way.
const content = contentToString(response?.content);
return parseIntent(content);
const parsed = parseModelJson(content);
return parsed ? parseResult(parsed) : null;
} catch (e) {
log.warn(`Intent classification failed for prompt; persisting null: ${e.message}`);
return null;
Expand Down
12 changes: 8 additions & 4 deletions src/support/serenity/brand-provisioning.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ import { isSemrushTransportError } from './errors.js';
import { resolveWorkspaceId } from './workspace-resolver.js';
import { deleteAllProjects, releaseFullAllocation, ensureSubworkspace } from './workspace-lifecycle.js';
import { handleCreateMarketSubworkspace } from './handlers/markets-subworkspace.js';

// Re-exported for callers/tests that drive brand provisioning. The tag
// vocabularies themselves live in `prompt-tags.js` (single source of truth).
import { computeWriteDeadline } from './intent-classification.js';

// Brand-create generation policy (tunable). Keep the top N generated topics by
// search volume; brand-topics returns up to 10 topics x up to 100 prompts each,
Expand Down Expand Up @@ -81,6 +79,10 @@ export function initialMarketProjectName(market, languageCode) {
* @param {object[]} [params.competitors] - the brand's competitors ("other
* brands to track") tracked as region-filtered project benchmarks (domain-only).
* Best-effort: a failed sync is logged and skipped, never aborts provisioning.
* @param {number} [params.writeDeadline] - shared request-write deadline (epoch
* ms), computed once at controller entry and threaded down so intent
* classification budgets against the true request start (serenity-docs#32);
* defaults to a fresh {@link computeWriteDeadline} for direct/test callers.
* @param {object} [log]
* @returns {Promise<{
* semrushSubWorkspaceId: string,
Expand All @@ -103,7 +105,7 @@ export function initialMarketProjectName(market, languageCode) {
export async function provisionBrandSubworkspace(context, {
spaceCatId, brandId, brandName, market, languageCode, brandDomain,
modelIds = [], brandAliases = [], brandUrlSources = null, competitors = [],
generateTopics = true,
generateTopics = true, writeDeadline = computeWriteDeadline(),
}, log = console) {
if (!hasText(brandName)) {
throw new ErrorWithStatusCode('brandName is required for Semrush provisioning', 400);
Expand Down Expand Up @@ -207,6 +209,8 @@ export async function provisionBrandSubworkspace(context, {
generateTopics,
topicCap: generateTopics ? MAX_TOPICS_ON_CREATE : 0,
brandAliases,
env: context.env,
writeDeadline,
brandUrlSources,
competitors,
// A project with neither models nor generated prompts would publish
Expand Down
Loading
Loading