feat(serenity): opt-in per-prompt userIntent on brand-presence prompts endpoint#2858
Conversation
Adds an opt-in `userIntent` query param to the Serenity brand-presence prompts endpoint. When set, each returned row gains its own classified intent (`userIntent`), derived by one `intent__<value>`-filtered PROMPTS element call per Semrush intent (parallel, joined by prompt text). Non-fatal: a classification-call failure degrades to blank `userIntent`, never failing the request. Without the flag, behavior and upstream call count are unchanged. Consumed by the Topic Intent Alignment tile (project-elmo-ui#2478) to build per-topic own-intent distributions from the full, uncapped prompt set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
This PR will trigger a minor release when merged. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…hment Drop the hardcoded intent-value array in the elements enrichment; iterate the canonical `INTENT_VALUE` from serenity/prompt-tags.js instead (the same `intent` closed-dimension vocabulary the write-path classifier derives its taxonomy from), so the enrichment's filter values can't drift from what the classifier writes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Hey @jjenscodee,
⚠ Degraded review - no spec document was found for this change (searched the PR links, the touched repos' docs, the architecture/guidelines docs, and linked Jira). This review covers code-level quality but could not validate the change against an agreed design, so confidence is reduced. Add a spec link (PR template section 4) and re-request review for a full-confidence pass.
Verdict: Request changes - two contract/resilience issues in the enrichment path need fixing before merge.
Complexity: MEDIUM - medium diff; API surface.
Changes: Adds opt-in per-prompt userIntent enrichment to the brand-presence prompts endpoint via parallel intent-filtered Semrush calls joined by prompt text (6 files).
Must fix before merge
- [Important] Count semantics diverge in enriched path -
src/support/elements/elements-service.js:197(details inline) - [Important] All-or-nothing degradation on intent fan-out catch -
src/support/elements/elements-service.js:178(details inline)
Non-blocking (4): minor issues and suggestions
- nit:
enrichUserIntentflag leaks through spread intobuildPromptsPayloadcalls; destructure it out before spreading -src/support/elements/elements-service.js:174 - nit: JSDoc for
getPromptscontains a duplicated "Non-fatal" sentence -src/support/elements/elements-service.js:144 - suggestion: Add a controller-level test that passes
?userIntent=trueand assertsenrichUserIntent: truereaches the service (only thefalsecase is covered today) -test/controllers/elements.test.js - suggestion: When this endpoint graduates from POC, add an OpenAPI entry for the
userIntentquery param to stay in sync with the documented OpenAPI-first convention.
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 12m 29s | Cost: $7.59 | Commit: f5dfdf3a71187d25cdc305b3f5ca5b30e25077d4
If this code review was useful, please react with 👍. Otherwise, react with 👎.
| userIntent: intentByPrompt.get(p.prompt) ?? '', | ||
| })); | ||
| return { count: prompts.length, prompts }; | ||
| }, |
There was a problem hiding this comment.
issue (blocking): The non-enriched path returns base.count (from transformPromptsResponse, which may carry a server-reported total for pagination). The enriched path returns { count: prompts.length, prompts }, replacing that with the local array length. If count ever differs from the row count (e.g. pagination metadata), this silently changes the contract for consumers who opt into userIntent=true.
Fix: return { count: base.count, prompts };
| return { key: value.toLowerCase(), rows: transformPromptsResponse(raw).prompts }; | ||
| }, | ||
| ).catch(() => []); | ||
|
|
There was a problem hiding this comment.
issue (blocking): The .catch(() => []) wraps the entire mapWithConcurrency promise. If mapWithConcurrency rejects on the first unhandled rejection (standard Promise behavior), a single failing intent call wipes ALL intent data - even if 4 of 5 calls succeeded. The stated guarantee is "non-fatal per row" but the implementation is "non-fatal per batch."
Fix: move error handling inside the callback so each intent value degrades independently:
async (value) => {
try {
const raw = await transport.fetchElement(...);
return { key: value.toLowerCase(), rows: transformPromptsResponse(raw).prompts };
} catch {
log.warn('intent enrichment failed for value', { value, workspaceId });
return { key: value.toLowerCase(), rows: [] };
}
}Then remove the outer .catch(() => []). This also adds the logging visibility that the current silent catch lacks.
…tent enrichment Addresses MysticatBot blocking review on #2858: - Move the intent-fan-out error handling inside the per-value callback so one failing intent-filtered call drops only that intent's rows, not the whole enrichment (was non-fatal per batch, now non-fatal per intent value). - Return the base response's `count` on the enriched path instead of the local array length, preserving the endpoint's count contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @MysticatBot — pushed fixes for both blocking issues (commit
Non-blocking items noted for follow-up: destructure Re-requesting review. |
There was a problem hiding this comment.
Hey @jjenscodee,
⚠ Degraded review - no spec document was found for this change (searched the PR links, the touched repos' docs, the architecture/guidelines docs, and linked Jira). This review covers code-level quality but could not validate the change against an agreed design, so confidence is reduced. Add a spec link (PR template section 4) and re-request review for a full-confidence pass.
Verdict: Approve - both prior blocking findings are correctly addressed; no remaining issues warrant blocking.
Complexity: MEDIUM - medium diff; API surface.
Changes: Adds opt-in per-prompt userIntent enrichment to the brand-presence prompts endpoint via parallel intent-filtered Semrush calls joined by prompt text (6 files).
Non-blocking (4): minor issues and suggestions
- suggestion: Add a controller-level test that passes
?userIntent=trueand assertsenrichUserIntent: truereaches the service - thefalsecase is covered but thetruewiring is untested at the integration level -test/controllers/elements.test.js - nit: The per-intent
catchblock silently returns empty rows with no logging - when a logger is injected into the service, add alog.warnwith the intent value and workspace ID for operational visibility -src/support/elements/elements-service.js:176 - nit:
enrichUserIntentpropagates into intent-filteredbuildPromptsPayloadcalls via spread - harmless today (the builder destructures only known keys) but destructuring it out before spreading would make the intent explicit -src/support/elements/elements-service.js:170 - nit: If a prompt text appeared in multiple intent-filtered results, last-write-wins on the
intentByPromptMap - the JSDoc correctly notes this shouldn't happen ("a prompt carries exactly one intent tag") but a brief inline comment on the.set()call would document the assumption for future readers -src/support/elements/elements-service.js:192
Previously flagged, now resolved
- Count semantics in enriched path now returns
base.countinstead of local array length - Per-intent error isolation moved inside the callback so one failing intent drops only that intent's rows
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 2m 32s | Cost: $6.19 | Commit: 278007d405cd84d4c23296dd9eb7c6502e985286
If this code review was useful, please react with 👍. Otherwise, react with 👎.
Multi-persona review — Approve with reservationsBot's two blocking items are addressed (all-or-nothing fix is genuinely correct + tested). Major — join rests on unverified upstream invariants. The join keys on free-text Major — silent degradation, no observability. Bare Minor — the count "fix" is a no-op. Minor: no controller test for Question: is PROMPTS row-capped? If so, base and intent calls cap independently → false-negative 🤖 Multi-persona review (Architect · DBA · Security · Tester/Junior) via Claude Code |
Addresses the human multi-persona review on #2858: - Join now keys on the (prompt, prompt_topic) tuple, not bare prompt text, and is gated to single-slice calls (exactly one projectId): the PROMPTS element row carries no stable id / geo / language, so a cross-market join can't be disambiguated — skip enrichment rather than risk mislabeling. - Thread a logger into the Elements service; the per-intent catch now log.warns the swallowed error instead of degrading silently. - Correct the misleading `count` comment (no server total exists; base.count currently equals the row count). - Destructure `enrichUserIntent` out before spreading into buildPromptsPayload; dedupe the "non-fatal" JSDoc. - Tests: single-slice guard, (prompt, prompt_topic) tuple disambiguation, and a ?userIntent=true controller-wiring assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @JayKid — solid review. Addressed in
Row-cap question: not observed for Lovesac (1,488 returned whole), but you're right it's a real risk if the element caps — the single-slice guard bounds the blast radius, and it's on the list for @ntoccane along with the stable-id ask. Left the OpenAPI entry for when this endpoint graduates from POC. Verified: |
JayKid
left a comment
There was a problem hiding this comment.
Re-review after fa5bba8b — Approve ✅
Both Major findings from my earlier review are resolved, and the join fix is handled the right way — gated rather than papered over.
Major — join invariants ✅ Now keys on the (prompt, prompt_topic) tuple and hard-gates enrichment to exactly one projectId, so the cross-market ambiguity can't arise (multi-slice requests never enrich). Correctly documents that the row exposes no semrushPromptId/geo/language and that a stable per-row id is the long-term fix (raised with @ntoccane). New tests cover both the tuple disambiguation and the single-slice gate.
Major — observability ✅ Logger threaded through the service; the per-intent catch now log.warns the swallowed error with workspaceId + message instead of degrading silently.
Minors ✅ Misleading count comment dropped; enrichUserIntent destructured out before the payload spread; ?userIntent=true controller-wiring test added; "non-fatal" JSDoc deduped.
Remaining items are all non-blocking, author's discretion:
- Base-call-failure path on the enriched branch still untested (JSDoc promises it propagates).
- The old
ANDs intent__X with a pre-existing tagassertion was replaced rather than kept — the enrichment tag-merge is no longer explicitly asserted. - Row-cap risk acknowledged and bounded by the single-slice guard; deferred to @ntoccane.
Clean, conservative POC change. LGTM.
🤖 Multi-persona review (Architect · DBA · Security · Tester/Junior) via Claude Code
# [1.673.0](v1.672.1...v1.673.0) (2026-07-21) ### Features * **serenity:** opt-in per-prompt userIntent on brand-presence prompts endpoint ([#2858](#2858)) ([5909ba1](5909ba1))
|
🎉 This PR is included in version 1.673.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
Adds an opt-in
userIntentenrichment to the Serenity brand-presence prompts endpoint (GET /v2/orgs/:orgId/brands/:brandId/serenity/brand-presence/prompts). When?userIntent=true, each returned row gains its own classified intent (userIntent) alongside the existingprompt/prompt_topic/primary_intent/volume.How
Per-prompt intent is not a column on the Semrush
PROMPTSelement, so it's derived the same way branded% already is: the basePROMPTScall + onetags:['intent__<Value>']-filtered call per Semrush intent, run in parallel (mapWithConcurrency) and joined back by prompt text. Non-fatal — a classification-call failure degrades each row touserIntent: '', never failing the request. Without the flag, response shape and upstream call count are unchanged (protects the existing branded% / intent-coverage caller,useSerenityPromptHealthStats).Why
The Topic Intent Alignment tile (project-elmo-ui#2478) compares each topic's expected intent against the distribution of its prompts' own intents. It previously joined against the ~1,000-capped prompt vocabulary; this lets it read per-prompt intent off the full, uncapped bp rows. Complements Niccolo's server-computed classification (serenity-docs#40, #2785) which populates the
intenttag this reads back.Verified
npm run type-checkclean ·npx eslintclean · unit tests (test/support/elements/elements-service.test.js+test/controllers/elements.test.js) — 131 passing.intent__<Value>tag filter verified live against the PROMPTS element (Lovesac:intent__Informational=1156,intent__Commercial=128 of 1488).Notes / follow-ups
intent__tags) if we want IT coverage here.🤖 Generated with Claude Code