@avocadostudio-ai/orchestrator-core 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/chat-pipeline.js +101 -14
- package/dist/chat/prompts.js +35 -2
- package/dist/handler/create-orchestrator.js +60 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +11 -0
- package/dist/nlp/intent-detection.d.ts +13 -0
- package/dist/nlp/intent-detection.js +12 -0
- package/dist/nlp/suggestion-engine.d.ts +191 -0
- package/dist/nlp/suggestion-engine.js +477 -0
- package/dist/state/session-state.d.ts +24 -0
- package/dist/state/session-state.js +45 -1
- package/dist/telemetry/chat-telemetry.d.ts +14 -0
- package/package.json +3 -3
|
@@ -4,12 +4,13 @@ import { blockManifestSchema } from "@avocadostudio-ai/shared";
|
|
|
4
4
|
import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGeneratingPlaceholder, cleanupImagePlaceholders, buildPageDirectory, isVariationRequestMessage, variationVerbIntent, resolveEffectiveSlug, throwIfCanceled, raceCancel, sleepMs, suppressCancelOnly } from "./chat-pipeline-shared.js";
|
|
5
5
|
import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
|
|
6
6
|
import { isLikelyClarificationFollowUp } from "../nlp/intent-helpers.js";
|
|
7
|
-
import { versions, pendingClarificationBySession, chatHistoryBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
|
|
7
|
+
import { versions, pendingClarificationBySession, chatHistoryBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage, getSuggestionMemory, recordSuggestionsShown, recordSuggestionClicked } from "../state/session-state.js";
|
|
8
8
|
import { getSiteAssets } from "../state/site-assets.js";
|
|
9
9
|
import { loadPendingPlan, savePendingPlan, clearPendingPlan } from "../durable/pending-plan-store.js";
|
|
10
10
|
import { envFlag } from "../env-flags.js";
|
|
11
11
|
import { toErrorDetail, isNoEffectiveChangeError, isAlreadyCurrentError, classifyGuardrailError, formatValidationError, isRepairEligibleCategory, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
|
|
12
12
|
import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
|
|
13
|
+
import { buildSuggestions, toLegacySuggestions } from "../nlp/suggestion-engine.js";
|
|
13
14
|
import { clarificationSuggestions, postEditSuggestions, keepDemoExecutable, demoPlanFromMessage, withKeylessNotice, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
|
|
14
15
|
import { generatePlanWithOpenAI, isPlannerOutputError, isStrictJsonResponseEnabled, parseIntentWithOpenAI } from "./planner.js";
|
|
15
16
|
import { isDemoModeEnabled, splitDemoOps, getDemoAllowedBlockTypes } from "../demo-mode.js";
|
|
@@ -186,17 +187,49 @@ export function setGeneratePlanWithAnthropicForTests(fn) {
|
|
|
186
187
|
generatePlanWithAnthropicImpl = fn ?? generatePlanWithAnthropic;
|
|
187
188
|
}
|
|
188
189
|
/**
|
|
189
|
-
*
|
|
190
|
+
* Rank, validate and record the pills for one turn.
|
|
190
191
|
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
192
|
+
* Replaces `usableSuggestions`, which did one job — drop the chips a keyless
|
|
193
|
+
* demo planner cannot answer — and did it only on the keyless path. That left
|
|
194
|
+
* the strongest instrument in the file pointed at the path that matters least:
|
|
195
|
+
* the demo chips were verified executable, and the keyed chips almost every
|
|
196
|
+
* real user sees went to the wire exactly as the model wrote them.
|
|
197
|
+
*
|
|
198
|
+
* Now every candidate — model, page gap, refinement, structure, site context —
|
|
199
|
+
* passes the same validator and competes on the same scale, the keyless
|
|
200
|
+
* executability filter still runs last on the path that needs it, and what was
|
|
201
|
+
* shown is recorded so the next turn can stop offering it.
|
|
195
202
|
*/
|
|
196
|
-
function
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
203
|
+
function resolveSuggestions(args) {
|
|
204
|
+
const memory = args.session ? getSuggestionMemory(args.session) : { shown: [], clicked: [] };
|
|
205
|
+
let ranked = buildSuggestions({
|
|
206
|
+
page: args.page,
|
|
207
|
+
mode: args.mode,
|
|
208
|
+
plan: args.plan,
|
|
209
|
+
modelSuggestions: args.modelSuggestions,
|
|
210
|
+
fallbackSuggestions: args.fallbackSuggestions,
|
|
211
|
+
shown: new Set(memory.shown),
|
|
212
|
+
clicked: new Set(memory.clicked),
|
|
213
|
+
availableSlugs: args.availableSlugs,
|
|
214
|
+
sitePurpose: args.body.sitePurpose,
|
|
215
|
+
siteTone: typeof args.body.siteContext === "object" ? args.body.siteContext?.tone : undefined
|
|
216
|
+
});
|
|
217
|
+
/*
|
|
218
|
+
* The keyless path, last. With no provider the answer comes from
|
|
219
|
+
* `demoPlanFromMessage`, a short list of literal English matchers, and a pill
|
|
220
|
+
* is sent verbatim on click — so a phrasing the matcher does not know is a
|
|
221
|
+
* dead button on the first screen of an install whose banner promises "no API
|
|
222
|
+
* key needed to look around". Returning fewer, or none, is the intended
|
|
223
|
+
* outcome.
|
|
224
|
+
*/
|
|
225
|
+
if (args.source === "demo") {
|
|
226
|
+
const executable = new Set(keepDemoExecutable(ranked.map((entry) => entry.prompt), args.slug, args.body.activeBlockId, args.body.activeBlockType));
|
|
227
|
+
ranked = ranked.filter((entry) => executable.has(entry.prompt));
|
|
228
|
+
}
|
|
229
|
+
if (args.session && ranked.length > 0) {
|
|
230
|
+
recordSuggestionsShown(args.session, ranked.map((entry) => entry.id));
|
|
231
|
+
}
|
|
232
|
+
return { suggestions: toLegacySuggestions(ranked), suggestionsV2: ranked };
|
|
200
233
|
}
|
|
201
234
|
let demoPlanFromMessageImpl = demoPlanFromMessage;
|
|
202
235
|
export function setDemoPlanFromMessageForTests(fn) {
|
|
@@ -739,7 +772,22 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
739
772
|
promptHash,
|
|
740
773
|
promptExcerpt,
|
|
741
774
|
promptLength: plannerMessage.length,
|
|
742
|
-
totalDurationMs: doneAtMs
|
|
775
|
+
totalDurationMs: doneAtMs,
|
|
776
|
+
/*
|
|
777
|
+
* Read straight off the payload rather than plumbed through the
|
|
778
|
+
* pipeline: this is the one place that sees every finished turn,
|
|
779
|
+
* and `suggestionsV2` is already on the object. `clickedSuggestionId`
|
|
780
|
+
* on the same row is what closes the loop — it says this turn was a
|
|
781
|
+
* click on a pill offered earlier, and the `opCount` recorded for
|
|
782
|
+
* the turn says whether that click was worth making.
|
|
783
|
+
*/
|
|
784
|
+
...(payload.suggestionsV2?.length
|
|
785
|
+
? {
|
|
786
|
+
suggestionIds: payload.suggestionsV2.map((entry) => entry.id),
|
|
787
|
+
suggestionSources: payload.suggestionsV2.map((entry) => entry.source)
|
|
788
|
+
}
|
|
789
|
+
: {}),
|
|
790
|
+
...(body.clickedSuggestionId ? { clickedSuggestionId: body.clickedSuggestionId } : {})
|
|
743
791
|
});
|
|
744
792
|
}
|
|
745
793
|
return stageTimeline.slice();
|
|
@@ -779,6 +827,15 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
779
827
|
promptLength: plannerMessage.length,
|
|
780
828
|
totalDurationMs: 0
|
|
781
829
|
});
|
|
830
|
+
/*
|
|
831
|
+
* A clicked pill is one the user acted on: it must not be offered again, and
|
|
832
|
+
* the ranker drops a clicked id outright. Recorded here, before any branch
|
|
833
|
+
* can return, so it holds for every outcome — including the ones that answer
|
|
834
|
+
* without planning anything.
|
|
835
|
+
*/
|
|
836
|
+
if (body.session && body.clickedSuggestionId) {
|
|
837
|
+
recordSuggestionClicked(body.session, body.clickedSuggestionId);
|
|
838
|
+
}
|
|
782
839
|
if (executionMode === "auto") {
|
|
783
840
|
const existingPendingPlan = await loadPendingPlan(body.session);
|
|
784
841
|
const normalizedIncomingMessage = typeof body.message === "string" ? body.message.trim() : "";
|
|
@@ -1127,7 +1184,15 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1127
1184
|
summary: "I need one more detail before applying this safely.",
|
|
1128
1185
|
changes: [],
|
|
1129
1186
|
mentionedSlugs: [effectiveSlug],
|
|
1130
|
-
|
|
1187
|
+
...resolveSuggestions({
|
|
1188
|
+
fallbackSuggestions: clarificationSuggestions({ body, current, selected }),
|
|
1189
|
+
mode: "clarification",
|
|
1190
|
+
page: current,
|
|
1191
|
+
source: args.source,
|
|
1192
|
+
slug: effectiveSlug,
|
|
1193
|
+
session: body.session,
|
|
1194
|
+
body
|
|
1195
|
+
}),
|
|
1131
1196
|
previewVersion: versions.get(body.session) ?? 0,
|
|
1132
1197
|
plannerSource: args.source,
|
|
1133
1198
|
modelUsed,
|
|
@@ -1751,7 +1816,16 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1751
1816
|
summary: resolvedPlan.summary_for_user,
|
|
1752
1817
|
changes: resolvedPlan.change_log,
|
|
1753
1818
|
mentionedSlugs: collectMentionedSlugsFromPlan(resolvedPlan, effectiveSlug),
|
|
1754
|
-
|
|
1819
|
+
...resolveSuggestions({
|
|
1820
|
+
modelSuggestions: resolvedPlan.suggested_next_actions,
|
|
1821
|
+
fallbackSuggestions: clarificationSuggestions({ body, current, selected }),
|
|
1822
|
+
mode: "clarification",
|
|
1823
|
+
page: current,
|
|
1824
|
+
source,
|
|
1825
|
+
slug: effectiveSlug,
|
|
1826
|
+
session: body.session,
|
|
1827
|
+
body
|
|
1828
|
+
}),
|
|
1755
1829
|
previewVersion: versions.get(body.session) ?? 0,
|
|
1756
1830
|
plannerSource: source,
|
|
1757
1831
|
modelUsed,
|
|
@@ -2655,7 +2729,20 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
2655
2729
|
summary: futureToPastTense(resolvedPlan.summary_for_user),
|
|
2656
2730
|
changes: [...opChangeLogEntries, ...metaChangeLogEntries, ...aiInsightChanges, ...skippedSummary],
|
|
2657
2731
|
mentionedSlugs: collectMentionedSlugsFromPlan(resolvedPlan, updatedSlug ?? effectiveSlug),
|
|
2658
|
-
|
|
2732
|
+
...resolveSuggestions({
|
|
2733
|
+
modelSuggestions: resolvedPlan.suggested_next_actions,
|
|
2734
|
+
fallbackSuggestions: postEditSuggestions({ plan: resolvedPlan, current, body }),
|
|
2735
|
+
mode: "followup",
|
|
2736
|
+
// The post-apply page, not `current`: a gap the edit just filled
|
|
2737
|
+
// must not still be offered as a suggestion, and `current` is the
|
|
2738
|
+
// pre-apply snapshot.
|
|
2739
|
+
page: getSessionDraft(body.session).get(updatedSlug ?? effectiveSlug) ?? current,
|
|
2740
|
+
plan: resolvedPlan,
|
|
2741
|
+
source: plannerSource,
|
|
2742
|
+
slug: updatedSlug ?? effectiveSlug,
|
|
2743
|
+
session: body.session,
|
|
2744
|
+
body
|
|
2745
|
+
}),
|
|
2659
2746
|
previewVersion,
|
|
2660
2747
|
focusBlockId,
|
|
2661
2748
|
updatedSlug,
|
package/dist/chat/prompts.js
CHANGED
|
@@ -134,7 +134,23 @@ function buildLightweightPlannerPrompt(opts) {
|
|
|
134
134
|
RULE_ICON_FORMAT,
|
|
135
135
|
"Use future tense in summary_for_user and change_log — your output streams to the user while the plan is still being generated, before any ops have been applied. Say 'Will update the heading…' or 'Will replace the Hero image…', never 'Updated…' or 'Updating…'. The system flips to past tense automatically once ops are applied.",
|
|
136
136
|
"For edit_plan: summary_for_user must be ONE short sentence (max ~20 words).",
|
|
137
|
-
|
|
137
|
+
/*
|
|
138
|
+
* The block list here used to be the twenty Avocado built-ins, spelled out
|
|
139
|
+
* verbatim, on every request — including requests from sites whose
|
|
140
|
+
* catalogue is entirely their own. On those the prompt was actively
|
|
141
|
+
* steering the model toward blocks the site has no renderer for, and
|
|
142
|
+
* `add_block` for one of those applies cleanly, reports success and draws
|
|
143
|
+
* nothing. `opts.effectiveBlockTypes` is the list that is true for THIS
|
|
144
|
+
* site, and it was already on the options object.
|
|
145
|
+
*
|
|
146
|
+
* The long tail of "NEVER suggest forms / popups / A/B tests / undo" is
|
|
147
|
+
* shortened, not because the model may now do those things but because
|
|
148
|
+
* saying it at length three times across two prompts never stopped it:
|
|
149
|
+
* nothing checked the output. `suggestion-engine.ts` now rejects every one
|
|
150
|
+
* of them on every candidate whatever its source. What is left is the short
|
|
151
|
+
* form, which still saves the model from spending a slot on one.
|
|
152
|
+
*/
|
|
153
|
+
`After planning ops, include suggested_next_actions: 2-4 short imperative phrases the user could type next (max 6 words each). Each must be a complete instruction the editor can execute \u2014 name the change, not just the field ("Make it shorter", not "Edit heading"). The only block types on this site are: ${opts.effectiveBlockTypes.join(", ")}. Never suggest a section outside that list, anything needing custom code (forms, popups, checkout), anything outside the editor (A/B tests, analytics), or undoing/reverting. When the plan contains exactly one update_props op on a text field, the first 1-2 suggestions MUST refine that same field.`,
|
|
138
154
|
opts.selectedBlockId.length > 0
|
|
139
155
|
? `Selected block is ${opts.selectedBlockId}. Target only this block in ops when the request edits the current page. IGNORE this selection when the request operates on a different scope — creating, duplicating, renaming, removing, or moving a page; editing site config; or naming a different page — and emit ops only for the requested scope. Never add bonus ops on the selected block to satisfy this rule.`
|
|
140
156
|
: "Respect explicit user target references when present.",
|
|
@@ -250,7 +266,24 @@ function sectionVoice(opts, hasNativeTools) {
|
|
|
250
266
|
if (hasNativeTools) {
|
|
251
267
|
lines.push("For edit_plan intent: summary_for_user must be ONE short sentence (max ~20 words) describing what the plan will do. Do NOT elaborate, explain why, or describe the content being added — let change_log carry the detail. Bad: 'Updated the hero heading with a punchier tone.' Good: 'Will add a **text section** about blueberry varieties after the features grid.'", "change_log coverage is MANDATORY: emit exactly one change_log entry per op, in the same order as ops[], describing what that specific op does. If ops has N entries, change_log must have N entries — never cluster multiple ops into one entry, never skip an op, never leave an op undescribed. The user reads change_log to decide whether to approve; a missing entry is a silent bait-and-switch.", "change_log entries should add specific detail NOT already in summary_for_user — e.g. list the actual content, items, or values being set. Do not paraphrase the summary.");
|
|
252
268
|
}
|
|
253
|
-
lines.push("In summary_for_user, use simple markdown for readability: **bold** for key terms or labels, and bullet lists (- item) when listing multiple items, recommendations, or observations. Keep it scannable — avoid walls of text.", "When rewriting text, return plain text unless the prop is a rich-text prop (see below) or the user explicitly asks for markdown formatting. Do not wrap the entire rewrite in **bold** markers.", RULE_RICH_TEXT_PROPS, "For copy in German or similar long-compound languages, insert soft hyphen opportunities in long compounds where helpful for responsive line wrapping. Use the Unicode soft hyphen character (U+00AD), never HTML entities like ­ or ­.", opts.provider !== "openai" ? BLOCK_NAME_PRIVACY_ANTHROPIC : BLOCK_NAME_PRIVACY_OPENAI, "", "### suggested_next_actions",
|
|
269
|
+
lines.push("In summary_for_user, use simple markdown for readability: **bold** for key terms or labels, and bullet lists (- item) when listing multiple items, recommendations, or observations. Keep it scannable — avoid walls of text.", "When rewriting text, return plain text unless the prop is a rich-text prop (see below) or the user explicitly asks for markdown formatting. Do not wrap the entire rewrite in **bold** markers.", RULE_RICH_TEXT_PROPS, "For copy in German or similar long-compound languages, insert soft hyphen opportunities in long compounds where helpful for responsive line wrapping. Use the Unicode soft hyphen character (U+00AD), never HTML entities like ­ or ­.", opts.provider !== "openai" ? BLOCK_NAME_PRIVACY_ANTHROPIC : BLOCK_NAME_PRIVACY_OPENAI, "", "### suggested_next_actions",
|
|
270
|
+
/*
|
|
271
|
+
* Shortened from about 1,900 characters, and the deleted half was all
|
|
272
|
+
* prohibition: no forms, no email capture, no popups, no chat widgets, no
|
|
273
|
+
* payment, no A/B tests, no analytics, no user research, no undo, no block
|
|
274
|
+
* outside the catalogue. Every one of those was also stated in the
|
|
275
|
+
* lightweight prompt, and none of them was ever checked \u2014
|
|
276
|
+
* `suggested_next_actions` went to the wire exactly as written.
|
|
277
|
+
*
|
|
278
|
+
* They are predicates now, in `suggestion-engine.ts`, applied to every
|
|
279
|
+
* candidate whatever produced it. The short form stays so the model does
|
|
280
|
+
* not spend a slot on a suggestion that will be dropped; what it no longer
|
|
281
|
+
* has to do is carry the enforcement.
|
|
282
|
+
*
|
|
283
|
+
* What is kept in full is the part no validator can check: that a good
|
|
284
|
+
* suggestion is a follow-up to THIS edit rather than a generic next move.
|
|
285
|
+
*/
|
|
286
|
+
"2-4 short imperative phrases the user could type next (max 6 words each). Each MUST be a logical follow-up to the specific change just made \u2014 not a generic action. Ask yourself: 'what would the user likely want to do next given THIS edit?' When the plan contains exactly one update_props op that changes a text field, the first 1-2 suggestions MUST be refinements of that same field (e.g. 'Make it shorter', 'Try a bolder tone', 'Use a warmer opening'). After rewriting stats labels, refine the same section ('Make the numbers bigger', 'Add a stat about X') \u2014 not unrelated actions like 'Change title' or 'Add a Testimonials section'. For needs_clarification, suggest the most likely concrete answers. Omit suggested_next_actions entirely if no contextual follow-up is obvious. Each must name the change and not just the field: 'Make it shorter', never 'Edit heading' \u2014 the planner knows the field and not the value, so the latter can only be answered with a question. NEVER suggest 'Open /X' or any navigation to a page the current plan is still creating; suggestions are sent verbatim as the next chat command and there is no navigation handler. Restricted to the block types in blockContracts / blockCatalogue for THIS site, or SEO/site-config edits \u2014 the catalogue is the whole list, not a sample. No forms, popups, checkout or anything else needing custom code; nothing outside the editor such as A/B testing or analytics; never undo, revert or restore.");
|
|
254
287
|
return lines;
|
|
255
288
|
}
|
|
256
289
|
/*
|
|
@@ -31,7 +31,9 @@ import { runChatPipeline, collectMentionedSlugsFromOps } from "../chat/chat-pipe
|
|
|
31
31
|
import { createChatTelemetryStore } from "../telemetry/chat-telemetry.js";
|
|
32
32
|
import { createToolRuntime } from "../tools/runtime.js";
|
|
33
33
|
import { envFlag } from "../env-flags.js";
|
|
34
|
-
import { loadStateFromDisk, scopedSessionKey, getSessionPages, getPage, getSiteConfig, setSiteConfig, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, schedulePersistState, normalizeSiteId, publishStatusBySession, pushPublishLogEntry, persistenceHealth, persistenceWarning } from "../state/session-state.js";
|
|
34
|
+
import { loadStateFromDisk, scopedSessionKey, getSessionPages, getPage, getSiteConfig, setSiteConfig, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, schedulePersistState, normalizeSiteId, publishStatusBySession, pushPublishLogEntry, persistenceHealth, persistenceWarning, getSuggestionMemory } from "../state/session-state.js";
|
|
35
|
+
import { buildSuggestions } from "../nlp/suggestion-engine.js";
|
|
36
|
+
import { keepDemoExecutable } from "../nlp/deterministic-planner-suggestions.js";
|
|
35
37
|
import { defaultModelLookup, defaultProviders } from "../chat/model-defaults.js";
|
|
36
38
|
import { historyStatus, historyLog, historyUndoAction, historyRedoAction, historyRestoreAction, historyDiscardAction } from "../http/history-actions.js";
|
|
37
39
|
import { whoamiAction } from "../http/session-actions.js";
|
|
@@ -261,6 +263,7 @@ const SUPPORTED_ROUTES = [
|
|
|
261
263
|
"POST /publish",
|
|
262
264
|
"GET /status/planner",
|
|
263
265
|
"GET /draft/pages",
|
|
266
|
+
"GET /suggestions",
|
|
264
267
|
"GET /draft/slugs",
|
|
265
268
|
"POST /draft/bootstrap",
|
|
266
269
|
"GET+PUT /draft/site-config",
|
|
@@ -1208,6 +1211,62 @@ export function createOrchestrator(config = {}) {
|
|
|
1208
1211
|
return jsonResponse({ error: "not found" }, { status: 404, cors });
|
|
1209
1212
|
return jsonResponse(structuredClone(page), { status: 200, cors });
|
|
1210
1213
|
}
|
|
1214
|
+
/*
|
|
1215
|
+
* The pills for a page nobody has chatted about yet.
|
|
1216
|
+
*
|
|
1217
|
+
* The editor used to derive its opening suggestions in the browser, from
|
|
1218
|
+
* the list of block *types* on the page and a table of translated strings.
|
|
1219
|
+
* It could not see what was in those blocks, so it offered "Add an FAQ
|
|
1220
|
+
* section" to a page that already had one until a guard was written for
|
|
1221
|
+
* that specific case, and it had no way at all to notice that the hero's
|
|
1222
|
+
* image was empty or that the page had no meta description.
|
|
1223
|
+
*
|
|
1224
|
+
* The engine can see all of that, and it is the same engine that ranks the
|
|
1225
|
+
* pills after every later turn — so the first screen and the tenth now
|
|
1226
|
+
* answer "what next?" the same way.
|
|
1227
|
+
*/
|
|
1228
|
+
if (request.method === "GET" && path === "/suggestions") {
|
|
1229
|
+
const runtime = await getRuntime();
|
|
1230
|
+
await runtime.ready;
|
|
1231
|
+
const session = url.searchParams.get("session") ?? undefined;
|
|
1232
|
+
const siteId = url.searchParams.get("siteId") ?? undefined;
|
|
1233
|
+
const slug = url.searchParams.get("slug");
|
|
1234
|
+
if (!session || !slug) {
|
|
1235
|
+
return jsonResponse({ error: "session and slug are required" }, { status: 400, cors });
|
|
1236
|
+
}
|
|
1237
|
+
const scopedSession = scope(session, siteId);
|
|
1238
|
+
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
1239
|
+
const page = getPage(scopedSession, slug);
|
|
1240
|
+
if (!page)
|
|
1241
|
+
return jsonResponse({ error: "not found" }, { status: 404, cors });
|
|
1242
|
+
const memory = getSuggestionMemory(scopedSession);
|
|
1243
|
+
let suggestionsV2 = buildSuggestions({
|
|
1244
|
+
page,
|
|
1245
|
+
shown: new Set(memory.shown),
|
|
1246
|
+
clicked: new Set(memory.clicked),
|
|
1247
|
+
siteTone: url.searchParams.get("tone") ?? undefined
|
|
1248
|
+
});
|
|
1249
|
+
/*
|
|
1250
|
+
* With no provider configured a pill is answered by `demoPlanFromMessage`
|
|
1251
|
+
* — a short list of literal English matchers — and the pill text is sent
|
|
1252
|
+
* verbatim on click, so anything the matcher does not know is a dead
|
|
1253
|
+
* button. These are the first three a new user sees, under a banner
|
|
1254
|
+
* promising no API key is needed to look around. The caller says whether
|
|
1255
|
+
* that is the situation; the editor is already showing that badge.
|
|
1256
|
+
*/
|
|
1257
|
+
if (url.searchParams.get("keyless") === "true") {
|
|
1258
|
+
const executable = new Set(keepDemoExecutable(suggestionsV2.map((entry) => entry.prompt), slug));
|
|
1259
|
+
suggestionsV2 = suggestionsV2.filter((entry) => executable.has(entry.prompt));
|
|
1260
|
+
}
|
|
1261
|
+
/*
|
|
1262
|
+
* Deliberately NOT recorded as shown. The welcome row is re-fetched
|
|
1263
|
+
* whenever the site's identity resolves late — which in production is
|
|
1264
|
+
* every load, because the auth handshake makes `/status/planner` lose the
|
|
1265
|
+
* race — so recording here would decay a pill on the same screen that
|
|
1266
|
+
* first drew it.
|
|
1267
|
+
*/
|
|
1268
|
+
return jsonResponse({ suggestions: suggestionsV2.map((entry) => entry.prompt), suggestionsV2 }, { status: 200, cors });
|
|
1269
|
+
}
|
|
1211
1270
|
// Page list. Also flips the editor's `hasBootstrapped` gate — until this
|
|
1212
1271
|
// returns a non-empty list, the property panel never enables its fetch.
|
|
1213
1272
|
if (request.method === "GET" && path === "/draft/slugs") {
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, Cm
|
|
|
6
6
|
export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaUploader, cmsMediaLabel, type JsonFileAdapterOptions, type EditorApiAdapterOptions, type CmsMediaSource, type CmsMediaUploader, type CmsMediaSourceConfig } from "./cms/index.ts";
|
|
7
7
|
export { registerPublishTarget, selectPublishTarget, getPublishTarget, listPublishTargets } from "./publish/publish-target-registry.ts";
|
|
8
8
|
export type { PublishTarget, PublishContext, PublishOutcome, PublishStatus, PublishResult } from "./publish/publish-target.ts";
|
|
9
|
+
export { buildSuggestions, validateSuggestion, toLegacySuggestions, suggestionId, SUGGESTION_LIMIT } from "./nlp/suggestion-engine.ts";
|
|
10
|
+
export type { Suggestion, SuggestionCandidate, SuggestionContext, SuggestionKind, SuggestionMode, SuggestionSource, ValidationFailure } from "./nlp/suggestion-engine.ts";
|
|
9
11
|
export { SqliteDurableStore, InMemoryDurableStore, getDurableStore, resetDurableStore, setDurableStore, durableStoreIsEphemeral, type SqliteDurableStoreOptions, type InMemoryDurableStoreOptions, type DurableStore, type FindingInput, type FindingRecord, type FindingQuery, type FindingSeverity, type FindingStatus, type FindingEvidence, type CheckRunInput, type CheckRunRecord, type CheckRunPatch, type CheckRunTrigger, type MemoryInput, type MemoryRecord, type MemoryQuery, type MemoryScope, type MemoryKind, type MemorySource, type MemoryStatus, type CorrectionInput, type CorrectionRecord, type CorrectionQuery, type CorrectionOutcome, type ProposalInput, type ProposalRecord, type ProposalQuery, type ProposalStatus } from "./durable/index.ts";
|
|
10
12
|
export { runDraftChecks, runChecksForSession, scheduleChecksAfterApply, scheduleChecksAfterPublish, cancelScheduledChecks, fingerprintFor, DRAFT_RULES, walkPageFields, fieldText, type RunChecksArgs, type CheckRule, type CheckContext, type RuleFinding, type FieldEntry, type SiteView } from "./checks/index.ts";
|
|
11
13
|
export { runChecksAction, listFindingsAction, listCheckRunsAction, updateFindingAction, type RunChecksParams, type ListFindingsParams, type UpdateFindingParams, type ChecksScope } from "./http/checks-actions.ts";
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,17 @@ export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource,
|
|
|
40
40
|
// integration point for "S3, GitLab Pages, Netlify, a CMS API, a custom CI/CD
|
|
41
41
|
// pipeline". Only the export was missing.
|
|
42
42
|
export { registerPublishTarget, selectPublishTarget, getPublishTarget, listPublishTargets } from "./publish/publish-target-registry.js";
|
|
43
|
+
// The suggestion engine. `Suggestion` is public because it is a field on
|
|
44
|
+
// `ChatResult` — a consumer installing from the registry that reads
|
|
45
|
+
// `suggestionsV2` off a chat response has no other specifier that reaches the
|
|
46
|
+
// type, and an unreachable type on a public response shape is the defect this
|
|
47
|
+
// file's header describes.
|
|
48
|
+
//
|
|
49
|
+
// `validateSuggestion` and `buildSuggestions` are public for the other half:
|
|
50
|
+
// a library-mode host rendering its own pill UI should be able to rank and
|
|
51
|
+
// check candidates with the same rules the orchestrator applies, rather than
|
|
52
|
+
// reimplementing the list of things the editor cannot build.
|
|
53
|
+
export { buildSuggestions, validateSuggestion, toLegacySuggestions, suggestionId, SUGGESTION_LIMIT } from "./nlp/suggestion-engine.js";
|
|
43
54
|
// The durable substrate for findings, memory, corrections and proposals. It is
|
|
44
55
|
// public because the implementation is meant to be replaceable: a library-mode
|
|
45
56
|
// host on a serverless platform has no persistent disk for a SQLite file, and
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type BlockType, type EditPlan, type PageDoc, type PublishDiff, type Operation } from "@avocadostudio-ai/shared";
|
|
3
|
+
import type { Suggestion } from "./suggestion-engine.ts";
|
|
3
4
|
import { type ModelKey } from "../state/session-state.ts";
|
|
4
5
|
import { type GuardrailErrorCategory } from "../errors.ts";
|
|
5
6
|
export type { GuardrailErrorCategory };
|
|
@@ -97,6 +98,7 @@ export declare const chatRequestBodySchema: z.ZodObject<{
|
|
|
97
98
|
name: z.ZodOptional<z.ZodString>;
|
|
98
99
|
bytes: z.ZodOptional<z.ZodNumber>;
|
|
99
100
|
}, z.core.$strip>>>;
|
|
101
|
+
clickedSuggestionId: z.ZodOptional<z.ZodString>;
|
|
100
102
|
}, z.core.$strip>;
|
|
101
103
|
export type ChatRequestBody = z.infer<typeof chatRequestBodySchema>;
|
|
102
104
|
/** Per-op outcome surfaced to the editor for the plan-preview card. Mirrors the engine's OpResult. */
|
|
@@ -113,6 +115,17 @@ export type ChatResult = {
|
|
|
113
115
|
changes: string[];
|
|
114
116
|
mentionedSlugs?: string[];
|
|
115
117
|
suggestions?: string[];
|
|
118
|
+
/**
|
|
119
|
+
* The same suggestions, with the structure the pills are actually ranked and
|
|
120
|
+
* measured with: a stable id to join telemetry on, a label that can be
|
|
121
|
+
* shorter than the command it sends, and the source and evidence behind it.
|
|
122
|
+
*
|
|
123
|
+
* Additive on purpose. `suggestions: string[]` is part of this package's
|
|
124
|
+
* published surface and site-sdk and every other integrator read it;
|
|
125
|
+
* it stays, and it is a projection of this field
|
|
126
|
+
* (`suggestionsV2.map(s => s.prompt)`), so the two cannot disagree.
|
|
127
|
+
*/
|
|
128
|
+
suggestionsV2?: Suggestion[];
|
|
116
129
|
validationErrors?: unknown;
|
|
117
130
|
previewVersion: number;
|
|
118
131
|
focusBlockId?: string;
|
|
@@ -54,6 +54,18 @@ export const chatRequestBodySchema = z.object({
|
|
|
54
54
|
pendingPlanId: z.string().optional(),
|
|
55
55
|
continuationChainId: z.string().optional(),
|
|
56
56
|
attachments: z.array(chatAttachmentSchema).max(6).optional(),
|
|
57
|
+
/**
|
|
58
|
+
* Set when this message came from clicking a suggestion pill rather than from
|
|
59
|
+
* typing.
|
|
60
|
+
*
|
|
61
|
+
* Carried on the request the click already sends, rather than through a
|
|
62
|
+
* separate endpoint: the click *is* a chat turn, so there is nothing to
|
|
63
|
+
* correlate afterwards and no extra round trip to lose. It is what makes a
|
|
64
|
+
* pill measurable — until now nothing recorded which suggestions were shown,
|
|
65
|
+
* which were clicked, or whether a click produced any operations, so the
|
|
66
|
+
* question "are the pills any good" had no answer at all.
|
|
67
|
+
*/
|
|
68
|
+
clickedSuggestionId: z.string().max(64).optional(),
|
|
57
69
|
});
|
|
58
70
|
// ---------------------------------------------------------------------------
|
|
59
71
|
// Shared normaliser used by all intent detectors below.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One engine for suggestion pills.
|
|
3
|
+
*
|
|
4
|
+
* A pill is the only thing in this product that speaks first. Everything else
|
|
5
|
+
* the editor does, it does because somebody typed it. That makes a pill worth
|
|
6
|
+
* more than its size on screen and also more dangerous: `onSuggestionClick` is
|
|
7
|
+
* `text => submitChat(text)`, so the label is the prompt, sent verbatim, and a
|
|
8
|
+
* pill the planner cannot answer is a button that does nothing.
|
|
9
|
+
*
|
|
10
|
+
* Five separate pieces of code used to answer "what next?" — the editor's
|
|
11
|
+
* welcome builder, the model's `suggested_next_actions`, three deterministic
|
|
12
|
+
* fallbacks, six hardcoded arrays, and the property panel's field pills. They
|
|
13
|
+
* shared no type, no vocabulary and no quality bar, and every one of them ended
|
|
14
|
+
* in `.slice(0, 4)` over an append-ordered list, so the order a pill appeared
|
|
15
|
+
* in was the order somebody had typed it into a source file.
|
|
16
|
+
*
|
|
17
|
+
* This module replaces the ranking half of that. Candidates arrive from several
|
|
18
|
+
* cheap sources — the model among them, with a high prior but no privilege —
|
|
19
|
+
* pass one validator, and are scored against each other. The model can lose.
|
|
20
|
+
*
|
|
21
|
+
* The validator is the other half of the point. The planner prompts carry about
|
|
22
|
+
* 1,400 characters of "NEVER suggest forms / popups / A/B tests / undo /
|
|
23
|
+
* questions / blocks this site cannot draw", stated three times across two
|
|
24
|
+
* prompts and enforced nowhere: `suggested_next_actions` went to the wire
|
|
25
|
+
* untouched. Every one of those rules is a predicate here, applied to every
|
|
26
|
+
* candidate regardless of where it came from.
|
|
27
|
+
*/
|
|
28
|
+
import { type EditPlan, type PageDoc } from "@avocadostudio-ai/shared";
|
|
29
|
+
export type SuggestionKind =
|
|
30
|
+
/** Refines the thing the last edit just touched. */
|
|
31
|
+
"refine"
|
|
32
|
+
/** Fills a hole the page actually has — an empty image, a missing description. */
|
|
33
|
+
| "fill-gap"
|
|
34
|
+
/** Adds a section this site has a renderer for. */
|
|
35
|
+
| "add-section"
|
|
36
|
+
/** Page/site structure: new pages, ordering, navigation. */
|
|
37
|
+
| "structure"
|
|
38
|
+
/** Search metadata. */
|
|
39
|
+
| "seo" | "other";
|
|
40
|
+
export type SuggestionSource = "model" | "page-gap" | "refinement" | "structure" | "context";
|
|
41
|
+
export type Suggestion = {
|
|
42
|
+
/**
|
|
43
|
+
* Stable across turns for the same prompt — this is the join key that session
|
|
44
|
+
* memory dedupes on and that telemetry reports, so it must not embed a
|
|
45
|
+
* timestamp, a turn index or anything else that moves.
|
|
46
|
+
*/
|
|
47
|
+
id: string;
|
|
48
|
+
/** What the pill reads. Short enough to fit a chip. */
|
|
49
|
+
label: string;
|
|
50
|
+
/**
|
|
51
|
+
* What is sent on click. May be longer and more specific than the label —
|
|
52
|
+
* splitting the two is what lets a pill read "Add author photos" while
|
|
53
|
+
* sending a command the planner can actually execute. Before this existed,
|
|
54
|
+
* one string had to be both good chip copy and a complete instruction, and
|
|
55
|
+
* that tension is why the "Edit heading"-shaped pills had to be deleted
|
|
56
|
+
* outright rather than reworded.
|
|
57
|
+
*/
|
|
58
|
+
prompt: string;
|
|
59
|
+
kind: SuggestionKind;
|
|
60
|
+
source: SuggestionSource;
|
|
61
|
+
score: number;
|
|
62
|
+
/** Why this was offered. Carried into telemetry; never shown to the user. */
|
|
63
|
+
evidence?: string;
|
|
64
|
+
};
|
|
65
|
+
export type SuggestionCandidate = {
|
|
66
|
+
label: string;
|
|
67
|
+
prompt?: string;
|
|
68
|
+
kind: SuggestionKind;
|
|
69
|
+
source: SuggestionSource;
|
|
70
|
+
/** Source-level confidence before page-state scoring. 0..1. */
|
|
71
|
+
prior: number;
|
|
72
|
+
evidence?: string;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* What kind of turn these pills belong to — which changes what a good pill is.
|
|
76
|
+
*
|
|
77
|
+
* A `followup` pill is a standalone command: it is sent verbatim and has to
|
|
78
|
+
* stand on its own, so "Edit heading" is a dead button.
|
|
79
|
+
*
|
|
80
|
+
* A `clarification` pill is an *answer to a question the assistant just asked*.
|
|
81
|
+
* It does not travel alone: `plannerMessageWithPendingContext` composes it with
|
|
82
|
+
* the request that triggered the clarification, so "Edit heading" arrives at
|
|
83
|
+
* the planner as the original sentence plus "Clarification from user: Edit
|
|
84
|
+
* heading" — which resolves. Applying the follow-up rules here would delete
|
|
85
|
+
* every answer to the question and leave the user a row of unrelated
|
|
86
|
+
* suggestions instead.
|
|
87
|
+
*/
|
|
88
|
+
export type SuggestionMode = "followup" | "clarification";
|
|
89
|
+
export type SuggestionContext = {
|
|
90
|
+
page: PageDoc;
|
|
91
|
+
mode?: SuggestionMode;
|
|
92
|
+
/** The plan that just ran (or is held for approval), when there was one. */
|
|
93
|
+
plan?: EditPlan;
|
|
94
|
+
/** `suggested_next_actions` as the model returned them. */
|
|
95
|
+
modelSuggestions?: string[];
|
|
96
|
+
/**
|
|
97
|
+
* The path's own deterministic fallback — `clarificationSuggestions` or
|
|
98
|
+
* `postEditSuggestions`. A candidate source like any other, so it competes
|
|
99
|
+
* instead of being used only when the model said nothing.
|
|
100
|
+
*/
|
|
101
|
+
fallbackSuggestions?: string[];
|
|
102
|
+
/** Ids already shown to this session. */
|
|
103
|
+
shown?: ReadonlySet<string>;
|
|
104
|
+
/** Ids this session has clicked. */
|
|
105
|
+
clicked?: ReadonlySet<string>;
|
|
106
|
+
/** Free-text site purpose / tone, when the user has filled it in. */
|
|
107
|
+
sitePurpose?: string;
|
|
108
|
+
siteTone?: string;
|
|
109
|
+
/** Slugs that exist on the site, for structure candidates. */
|
|
110
|
+
availableSlugs?: string[];
|
|
111
|
+
limit?: number;
|
|
112
|
+
};
|
|
113
|
+
/** Three, not four. See `rank`. */
|
|
114
|
+
export declare const SUGGESTION_LIMIT = 3;
|
|
115
|
+
/**
|
|
116
|
+
* A content hash of the prompt, normalized so that casing and trailing
|
|
117
|
+
* punctuation do not mint a new id for the same suggestion. Deliberately not a
|
|
118
|
+
* cryptographic hash — this is a dedupe key, not a security boundary, and a
|
|
119
|
+
* short stable string is easier to read in a telemetry row.
|
|
120
|
+
*/
|
|
121
|
+
export declare function suggestionId(prompt: string): string;
|
|
122
|
+
export type ValidationFailure = "empty" | "too_long" | "question_or_offer" | "undo" | "unsupported_feature" | "underspecified" | "unrenderable_block";
|
|
123
|
+
export declare function validateSuggestion(text: string, opts?: {
|
|
124
|
+
mode?: SuggestionMode;
|
|
125
|
+
}): ValidationFailure | null;
|
|
126
|
+
/**
|
|
127
|
+
* The model's own suggestions, as candidates.
|
|
128
|
+
*
|
|
129
|
+
* A high prior — the model is the only source that read the user's sentence and
|
|
130
|
+
* the page's topic, and when it is right it is more specific than anything
|
|
131
|
+
* derivable. But it competes, and when it returns four variations on "add a
|
|
132
|
+
* section" the diversity rule in `rank` will keep one of them.
|
|
133
|
+
*/
|
|
134
|
+
export declare function modelCandidates(raw: string[] | undefined): SuggestionCandidate[];
|
|
135
|
+
/**
|
|
136
|
+
* Holes the page actually has.
|
|
137
|
+
*
|
|
138
|
+
* `pageObservations` already walks every declared field and returns
|
|
139
|
+
* `{ fact, fix }`, where `fix` is phrased as a command the planner can run. It
|
|
140
|
+
* was computed on every turn and handed to the model as prose — the one
|
|
141
|
+
* candidate source grounded in what is wrong with the page in front of the
|
|
142
|
+
* user, and the pill builders never saw it.
|
|
143
|
+
*/
|
|
144
|
+
export declare function pageGapCandidates(page: PageDoc): SuggestionCandidate[];
|
|
145
|
+
/**
|
|
146
|
+
* Refinements of the field the last edit just touched.
|
|
147
|
+
*
|
|
148
|
+
* The planner prompt asks for this in so many words — "When the plan contains
|
|
149
|
+
* exactly one update_props op that changes a text field, the first 1-2
|
|
150
|
+
* suggestions MUST be refinements of that same field" — and nothing has ever
|
|
151
|
+
* guaranteed it. Deriving it costs nothing and does.
|
|
152
|
+
*/
|
|
153
|
+
export declare function refinementCandidates(plan: EditPlan | undefined, page: PageDoc): SuggestionCandidate[];
|
|
154
|
+
/**
|
|
155
|
+
* Sections this site can draw and this page does not have.
|
|
156
|
+
*
|
|
157
|
+
* Read off `catalogueBlockTypes()` — never off the built-in names. A site's
|
|
158
|
+
* catalogue is the whole list, not a sample of a larger one.
|
|
159
|
+
*/
|
|
160
|
+
export declare function structureCandidates(page: PageDoc, availableSlugs?: string[]): SuggestionCandidate[];
|
|
161
|
+
/**
|
|
162
|
+
* The site's own voice, when the user has told us what it is.
|
|
163
|
+
*
|
|
164
|
+
* `sitePurpose` and tone reach the planner's context pack already; until now
|
|
165
|
+
* the only pills that used them were the welcome ones, and only as a template.
|
|
166
|
+
*/
|
|
167
|
+
export declare function contextCandidates(args: {
|
|
168
|
+
sitePurpose?: string;
|
|
169
|
+
siteTone?: string;
|
|
170
|
+
}): SuggestionCandidate[];
|
|
171
|
+
/**
|
|
172
|
+
* Score, deduplicate, diversify, cap.
|
|
173
|
+
*
|
|
174
|
+
* The cap is three rather than four, and returning fewer than three — or none —
|
|
175
|
+
* is a valid answer. Three real suggestions beat six of which one works, and an
|
|
176
|
+
* empty row leaves whatever the assistant said as the thing on screen, which is
|
|
177
|
+
* usually the more useful object anyway.
|
|
178
|
+
*/
|
|
179
|
+
export declare function rank(candidates: SuggestionCandidate[], ctx: SuggestionContext): Suggestion[];
|
|
180
|
+
/**
|
|
181
|
+
* The path's own deterministic fallback, as candidates.
|
|
182
|
+
*
|
|
183
|
+
* Given a slightly lower prior than the model's: when both have an opinion the
|
|
184
|
+
* model read the user's sentence and this did not. It still competes, and on
|
|
185
|
+
* the clarification path — where it is answering a specific question — it
|
|
186
|
+
* routinely wins.
|
|
187
|
+
*/
|
|
188
|
+
export declare function fallbackCandidates(raw: string[] | undefined): SuggestionCandidate[];
|
|
189
|
+
export declare function buildSuggestions(ctx: SuggestionContext): Suggestion[];
|
|
190
|
+
/** The legacy wire shape: what `suggestions: string[]` has always carried. */
|
|
191
|
+
export declare function toLegacySuggestions(suggestions: Suggestion[]): string[];
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One engine for suggestion pills.
|
|
3
|
+
*
|
|
4
|
+
* A pill is the only thing in this product that speaks first. Everything else
|
|
5
|
+
* the editor does, it does because somebody typed it. That makes a pill worth
|
|
6
|
+
* more than its size on screen and also more dangerous: `onSuggestionClick` is
|
|
7
|
+
* `text => submitChat(text)`, so the label is the prompt, sent verbatim, and a
|
|
8
|
+
* pill the planner cannot answer is a button that does nothing.
|
|
9
|
+
*
|
|
10
|
+
* Five separate pieces of code used to answer "what next?" — the editor's
|
|
11
|
+
* welcome builder, the model's `suggested_next_actions`, three deterministic
|
|
12
|
+
* fallbacks, six hardcoded arrays, and the property panel's field pills. They
|
|
13
|
+
* shared no type, no vocabulary and no quality bar, and every one of them ended
|
|
14
|
+
* in `.slice(0, 4)` over an append-ordered list, so the order a pill appeared
|
|
15
|
+
* in was the order somebody had typed it into a source file.
|
|
16
|
+
*
|
|
17
|
+
* This module replaces the ranking half of that. Candidates arrive from several
|
|
18
|
+
* cheap sources — the model among them, with a high prior but no privilege —
|
|
19
|
+
* pass one validator, and are scored against each other. The model can lose.
|
|
20
|
+
*
|
|
21
|
+
* The validator is the other half of the point. The planner prompts carry about
|
|
22
|
+
* 1,400 characters of "NEVER suggest forms / popups / A/B tests / undo /
|
|
23
|
+
* questions / blocks this site cannot draw", stated three times across two
|
|
24
|
+
* prompts and enforced nowhere: `suggested_next_actions` went to the wire
|
|
25
|
+
* untouched. Every one of those rules is a predicate here, applied to every
|
|
26
|
+
* candidate regardless of where it came from.
|
|
27
|
+
*/
|
|
28
|
+
import { catalogueBlockTypes, getBlockMeta } from "@avocadostudio-ai/shared";
|
|
29
|
+
import { pageObservations } from "./intent-detection.js";
|
|
30
|
+
/** Three, not four. See `rank`. */
|
|
31
|
+
export const SUGGESTION_LIMIT = 3;
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Identity
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
/**
|
|
36
|
+
* A content hash of the prompt, normalized so that casing and trailing
|
|
37
|
+
* punctuation do not mint a new id for the same suggestion. Deliberately not a
|
|
38
|
+
* cryptographic hash — this is a dedupe key, not a security boundary, and a
|
|
39
|
+
* short stable string is easier to read in a telemetry row.
|
|
40
|
+
*/
|
|
41
|
+
export function suggestionId(prompt) {
|
|
42
|
+
const normalized = prompt.trim().toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ");
|
|
43
|
+
let h1 = 0x811c9dc5;
|
|
44
|
+
let h2 = 0x01000193;
|
|
45
|
+
for (let i = 0; i < normalized.length; i += 1) {
|
|
46
|
+
const c = normalized.charCodeAt(i);
|
|
47
|
+
h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
|
|
48
|
+
h2 = Math.imul(h2 + c, 0x85ebca6b) >>> 0;
|
|
49
|
+
}
|
|
50
|
+
return `s_${h1.toString(36)}${h2.toString(36)}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Phrased as a question or an offer rather than a command.
|
|
54
|
+
*
|
|
55
|
+
* The prompt has told the model "NEVER phrase suggestions as questions
|
|
56
|
+
* ('Would you like me to…?')" since the page-feedback rule was written. A pill
|
|
57
|
+
* that reads "Would you like me to shorten it?" sends that string to the
|
|
58
|
+
* planner, which answers the question instead of doing the thing.
|
|
59
|
+
*/
|
|
60
|
+
const QUESTION_OR_OFFER = /^\s*(would|should|shall|could|can|do|does|did|is|are|want|how|what|why|when|where|which|who)\b|^\s*i\s+(can|could|will|would|might)\b|^\s*(let me|let's|maybe|perhaps|consider)\b/i;
|
|
61
|
+
/**
|
|
62
|
+
* Undo, revert, restore.
|
|
63
|
+
*
|
|
64
|
+
* The model cannot read the undo history, so such a suggestion resolves to
|
|
65
|
+
* either a refusal or an invented replacement presented as the original. The
|
|
66
|
+
* editor's own Undo button is the affordance, and it works.
|
|
67
|
+
*/
|
|
68
|
+
const UNDO_REQUEST = /\b(undo|revert|restore|roll\s?back|rolled back|put\s+(?:it|them|that|the\s+\w+)\s+back|bring\s+back|go back to the (?:previous|old|earlier))\b/i;
|
|
69
|
+
/**
|
|
70
|
+
* Capabilities the editor does not have. Each of these requires code the block
|
|
71
|
+
* pipeline cannot produce, so the planner's only honest answer is a refusal —
|
|
72
|
+
* after the user has spent a click and, on a keyed plan, a model call finding
|
|
73
|
+
* that out.
|
|
74
|
+
*/
|
|
75
|
+
const UNSUPPORTED_FEATURE = new RegExp([
|
|
76
|
+
// interactive widgets requiring custom code
|
|
77
|
+
"\\bforms?\\b", "\\bcontact form\\b", "\\bemail capture\\b", "\\bsubscribe\\b",
|
|
78
|
+
"\\bnewsletter\\b", "\\bsign[- ]?up form\\b", "\\bpop[- ]?ups?\\b", "\\bmodals?\\b",
|
|
79
|
+
"\\bchat widget\\b", "\\blive chat\\b", "\\bpayments?\\b", "\\bcheckout\\b",
|
|
80
|
+
"\\bshopping cart\\b", "\\bbooking (?:system|engine)\\b", "\\blogin\\b",
|
|
81
|
+
// out-of-scope activities
|
|
82
|
+
"\\ba/?b test", "\\bsplit test", "\\banalytics\\b", "\\bheat ?maps?\\b",
|
|
83
|
+
"\\bperformance monitoring\\b", "\\buser research\\b", "\\bmarketing strategy\\b",
|
|
84
|
+
"\\bconversion tracking\\b", "\\bemail campaign\\b"
|
|
85
|
+
].join("|"), "i");
|
|
86
|
+
/**
|
|
87
|
+
* Names the target and not the change.
|
|
88
|
+
*
|
|
89
|
+
* "Edit heading", "Edit CTA", "Update link" — the planner knows the field and
|
|
90
|
+
* not the value, so the only possible answer is "edit it to what?". Three
|
|
91
|
+
* clean-room runs measured the same outcome: zero operations, and on a keyed
|
|
92
|
+
* plan about $0.012 and five seconds to be asked a question the pill existed to
|
|
93
|
+
* avoid. `postEditSuggestions` learned this and stopped emitting the shape; the
|
|
94
|
+
* model never did, because nothing checked its output.
|
|
95
|
+
*
|
|
96
|
+
* Verbs that already imply "produce new content" — rewrite, shorten, punch up —
|
|
97
|
+
* are not caught: those are complete instructions on their own. Only the pure
|
|
98
|
+
* mutation verbs need a direction to be actionable.
|
|
99
|
+
*/
|
|
100
|
+
const MUTATION_VERB = /^\s*(edit|change|update|modify|adjust|tweak|set|customi[sz]e)\b/i;
|
|
101
|
+
const DIRECTION_MARKER = /\b(to|into|so|with|for|as|using|toward|towards|about|around|more|less|fewer|shorter|longer|bolder|warmer|punchier|friendlier|clearer|simpler|stronger|tighter|bigger|smaller)\b|["'“”]|\d/i;
|
|
102
|
+
/**
|
|
103
|
+
* Words a user would read as the name of a section, mapped to the built-in type
|
|
104
|
+
* behind them.
|
|
105
|
+
*
|
|
106
|
+
* This vocabulary exists to answer one question: is this pill proposing to add
|
|
107
|
+
* a block this site has no renderer for? `add_block` for an unregistered type
|
|
108
|
+
* applies cleanly, reports success and draws nothing — so the suggestion is not
|
|
109
|
+
* merely unhelpful, it is a trap, and `advice-catalogue.test.ts` documents the
|
|
110
|
+
* same trap being sprung on the advice path for months.
|
|
111
|
+
*
|
|
112
|
+
* Checked ONLY in an add/create context. A site whose hero is called
|
|
113
|
+
* `adv_heroSplit` still has a hero, and "Rewrite the hero headline" is a fine
|
|
114
|
+
* suggestion there — the planner resolves that by position and content, not by
|
|
115
|
+
* type name. It is only *adding* that needs a renderer to exist.
|
|
116
|
+
*/
|
|
117
|
+
const BUILTIN_SECTION_WORDS = [
|
|
118
|
+
[/\btestimonials?\b/i, "Testimonials"],
|
|
119
|
+
[/\bfaq\b|\bfrequently asked\b|\baccordion\b/i, "FAQAccordion"],
|
|
120
|
+
[/\bfeature grid\b|\bfeatures grid\b|\bfeature section\b/i, "FeatureGrid"],
|
|
121
|
+
[/\bcard grid\b|\bcards? section\b/i, "CardGrid"],
|
|
122
|
+
[/\bcall[- ]to[- ]action\b|\bcta\b/i, "CTA"],
|
|
123
|
+
[/\bhero\b/i, "Hero"],
|
|
124
|
+
[/\bbanner\b/i, "Banner"],
|
|
125
|
+
[/\bcarousel\b|\bslider\b/i, "Carousel"],
|
|
126
|
+
[/\bgallery\b/i, "Gallery"],
|
|
127
|
+
[/\bquote\b/i, "Quote"],
|
|
128
|
+
[/\bstats?\b|\bstatistics\b/i, "Stats"],
|
|
129
|
+
[/\btable\b/i, "Table"],
|
|
130
|
+
[/\btabs\b/i, "Tabs"],
|
|
131
|
+
[/\bvideo\b/i, "Video"],
|
|
132
|
+
[/\bfooter\b/i, "Footer"],
|
|
133
|
+
[/\brich ?text\b|\btext section\b/i, "RichText"],
|
|
134
|
+
[/\btwo[- ]column\b/i, "TwoColumn"],
|
|
135
|
+
[/\bembed\b/i, "Embed"]
|
|
136
|
+
];
|
|
137
|
+
const ADD_INTENT = /^\s*(add|create|insert|include|append|put in|drop in)\b/i;
|
|
138
|
+
/**
|
|
139
|
+
* Does this site have something that answers to this name?
|
|
140
|
+
*
|
|
141
|
+
* Checked against the catalogue's own display names and types first, so a site
|
|
142
|
+
* that calls its testimonials block `pba_voices` with display name "Voices"
|
|
143
|
+
* still matches a suggestion that says "voices". Only when nothing in the
|
|
144
|
+
* catalogue answers do we fall back to asking whether the phrase names an
|
|
145
|
+
* Avocado built-in that this site does not have.
|
|
146
|
+
*/
|
|
147
|
+
function catalogueAnswersTo(text) {
|
|
148
|
+
const lower = text.toLowerCase();
|
|
149
|
+
for (const type of catalogueBlockTypes()) {
|
|
150
|
+
const meta = getBlockMeta(type);
|
|
151
|
+
if (meta?.chrome)
|
|
152
|
+
continue;
|
|
153
|
+
const display = meta?.displayName;
|
|
154
|
+
if (display && lower.includes(display.toLowerCase()))
|
|
155
|
+
return true;
|
|
156
|
+
// Split camelCase / snake-prefixed type names into words: `adv_eventCards`
|
|
157
|
+
// reads as "event cards", which is how a suggestion would spell it.
|
|
158
|
+
const words = type
|
|
159
|
+
.replace(/^[a-z]{2,4}_/, "")
|
|
160
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
161
|
+
.toLowerCase();
|
|
162
|
+
if (words.length > 2 && lower.includes(words))
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
function namesUnrenderableBlock(text) {
|
|
168
|
+
if (!ADD_INTENT.test(text))
|
|
169
|
+
return false;
|
|
170
|
+
if (catalogueAnswersTo(text))
|
|
171
|
+
return false;
|
|
172
|
+
const catalogue = new Set(catalogueBlockTypes());
|
|
173
|
+
for (const [pattern, builtinType] of BUILTIN_SECTION_WORDS) {
|
|
174
|
+
if (pattern.test(text) && !catalogue.has(builtinType))
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
/** Longest a pill may be before it stops reading as a pill. */
|
|
180
|
+
const MAX_LABEL_CHARS = 60;
|
|
181
|
+
export function validateSuggestion(text, opts) {
|
|
182
|
+
const trimmed = text.trim();
|
|
183
|
+
if (trimmed.length === 0)
|
|
184
|
+
return "empty";
|
|
185
|
+
if (trimmed.length > MAX_LABEL_CHARS * 2)
|
|
186
|
+
return "too_long";
|
|
187
|
+
if (trimmed.endsWith("?") || QUESTION_OR_OFFER.test(trimmed))
|
|
188
|
+
return "question_or_offer";
|
|
189
|
+
if (UNDO_REQUEST.test(trimmed))
|
|
190
|
+
return "undo";
|
|
191
|
+
if (UNSUPPORTED_FEATURE.test(trimmed))
|
|
192
|
+
return "unsupported_feature";
|
|
193
|
+
// Skipped in clarification mode: the pill is an answer composed with the
|
|
194
|
+
// pending request, not a standalone command. See `SuggestionMode`.
|
|
195
|
+
if (opts?.mode !== "clarification" && MUTATION_VERB.test(trimmed) && !DIRECTION_MARKER.test(trimmed)) {
|
|
196
|
+
return "underspecified";
|
|
197
|
+
}
|
|
198
|
+
if (namesUnrenderableBlock(trimmed))
|
|
199
|
+
return "unrenderable_block";
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Candidate sources
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
/**
|
|
206
|
+
* The model's own suggestions, as candidates.
|
|
207
|
+
*
|
|
208
|
+
* A high prior — the model is the only source that read the user's sentence and
|
|
209
|
+
* the page's topic, and when it is right it is more specific than anything
|
|
210
|
+
* derivable. But it competes, and when it returns four variations on "add a
|
|
211
|
+
* section" the diversity rule in `rank` will keep one of them.
|
|
212
|
+
*/
|
|
213
|
+
export function modelCandidates(raw) {
|
|
214
|
+
if (!raw || raw.length === 0)
|
|
215
|
+
return [];
|
|
216
|
+
return raw.slice(0, 6).map((text) => ({
|
|
217
|
+
label: text.trim(),
|
|
218
|
+
kind: inferKind(text),
|
|
219
|
+
source: "model",
|
|
220
|
+
prior: 0.8,
|
|
221
|
+
evidence: "planner suggested_next_actions"
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
function inferKind(text) {
|
|
225
|
+
if (/\bmeta description\b|\bseo\b|\bsearch result\b|\bpage title\b/i.test(text))
|
|
226
|
+
return "seo";
|
|
227
|
+
if (/\bpage\b/i.test(text) && /^\s*(add|create|rename|delete|reorder|move)\b/i.test(text))
|
|
228
|
+
return "structure";
|
|
229
|
+
if (/^\s*(add|create|insert|include)\b/i.test(text))
|
|
230
|
+
return "add-section";
|
|
231
|
+
if (/^\s*(rewrite|shorten|lengthen|punch|polish|make|try|use)\b/i.test(text))
|
|
232
|
+
return "refine";
|
|
233
|
+
return "other";
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Holes the page actually has.
|
|
237
|
+
*
|
|
238
|
+
* `pageObservations` already walks every declared field and returns
|
|
239
|
+
* `{ fact, fix }`, where `fix` is phrased as a command the planner can run. It
|
|
240
|
+
* was computed on every turn and handed to the model as prose — the one
|
|
241
|
+
* candidate source grounded in what is wrong with the page in front of the
|
|
242
|
+
* user, and the pill builders never saw it.
|
|
243
|
+
*/
|
|
244
|
+
export function pageGapCandidates(page) {
|
|
245
|
+
return pageObservations(page).map((observation) => ({
|
|
246
|
+
label: observation.fix,
|
|
247
|
+
kind: /meta description|search result/i.test(observation.fact) ? "seo" : "fill-gap",
|
|
248
|
+
source: "page-gap",
|
|
249
|
+
// Below the model's prior: a gap is real but it is not necessarily what the
|
|
250
|
+
// user is thinking about right now. It wins when the model offers nothing
|
|
251
|
+
// better, which on a page with holes in it is often.
|
|
252
|
+
prior: 0.65,
|
|
253
|
+
evidence: observation.fact
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Refinements of the field the last edit just touched.
|
|
258
|
+
*
|
|
259
|
+
* The planner prompt asks for this in so many words — "When the plan contains
|
|
260
|
+
* exactly one update_props op that changes a text field, the first 1-2
|
|
261
|
+
* suggestions MUST be refinements of that same field" — and nothing has ever
|
|
262
|
+
* guaranteed it. Deriving it costs nothing and does.
|
|
263
|
+
*/
|
|
264
|
+
export function refinementCandidates(plan, page) {
|
|
265
|
+
if (!plan)
|
|
266
|
+
return [];
|
|
267
|
+
const updates = plan.ops.filter((op) => op.op === "update_props");
|
|
268
|
+
if (updates.length !== 1)
|
|
269
|
+
return [];
|
|
270
|
+
const [op] = updates;
|
|
271
|
+
if (!op || op.op !== "update_props")
|
|
272
|
+
return [];
|
|
273
|
+
const patch = op.patch;
|
|
274
|
+
const textKeys = Object.entries(patch)
|
|
275
|
+
.filter(([, value]) => typeof value === "string" && value.trim().length > 0)
|
|
276
|
+
.map(([key]) => key);
|
|
277
|
+
if (textKeys.length === 0)
|
|
278
|
+
return [];
|
|
279
|
+
const block = page.blocks.find((entry) => entry.id === op.blockId);
|
|
280
|
+
const noun = describeField(block?.type, textKeys[0]);
|
|
281
|
+
return [
|
|
282
|
+
{ label: "Make it shorter", kind: "refine", source: "refinement", prior: 0.75, evidence: `refines ${noun}` },
|
|
283
|
+
{ label: "Try a bolder tone", kind: "refine", source: "refinement", prior: 0.7, evidence: `refines ${noun}` },
|
|
284
|
+
{ label: `Rewrite ${noun} again`, kind: "refine", source: "refinement", prior: 0.6, evidence: `refines ${noun}` }
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
function describeField(blockType, key) {
|
|
288
|
+
const meta = blockType ? getBlockMeta(blockType) : undefined;
|
|
289
|
+
const label = meta?.fields?.[key]?.label;
|
|
290
|
+
if (label)
|
|
291
|
+
return `the ${label.toLowerCase()}`;
|
|
292
|
+
const humanized = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
293
|
+
return `the ${humanized}`;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Sections this site can draw and this page does not have.
|
|
297
|
+
*
|
|
298
|
+
* Read off `catalogueBlockTypes()` — never off the built-in names. A site's
|
|
299
|
+
* catalogue is the whole list, not a sample of a larger one.
|
|
300
|
+
*/
|
|
301
|
+
export function structureCandidates(page, availableSlugs) {
|
|
302
|
+
const present = new Set(page.blocks.map((block) => block.type));
|
|
303
|
+
const candidates = [];
|
|
304
|
+
const addable = catalogueBlockTypes().filter((type) => !present.has(type) && !getBlockMeta(type)?.chrome);
|
|
305
|
+
/*
|
|
306
|
+
* Conversion first, then content, then media. On a page that has no way to
|
|
307
|
+
* convert, adding one is the change most likely to matter; layout and
|
|
308
|
+
* navigation blocks are chrome-adjacent and rarely what someone wants
|
|
309
|
+
* suggested. Without this the order is registration order, which is not a
|
|
310
|
+
* ranking — it is the order somebody wrote the imports in.
|
|
311
|
+
*
|
|
312
|
+
* The category is read off the catalogue, which is the only list that knows
|
|
313
|
+
* what this site can draw.
|
|
314
|
+
*/
|
|
315
|
+
const byCategory = (category) => addable.filter((type) => getBlockMeta(type)?.category === category);
|
|
316
|
+
const ordered = [...byCategory("conversion"), ...byCategory("content"), ...byCategory("media")];
|
|
317
|
+
for (const type of ordered.slice(0, 3)) {
|
|
318
|
+
const name = getBlockMeta(type)?.displayName ?? type;
|
|
319
|
+
candidates.push({
|
|
320
|
+
label: `Add ${name}`,
|
|
321
|
+
prompt: `Add a ${name} section to this page`,
|
|
322
|
+
kind: "add-section",
|
|
323
|
+
source: "structure",
|
|
324
|
+
prior: 0.5,
|
|
325
|
+
evidence: `${type} is in this site's catalogue and not on this page`
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (availableSlugs && availableSlugs.length > 0 && !availableSlugs.includes("/about")) {
|
|
329
|
+
candidates.push({
|
|
330
|
+
label: "Create an /about page",
|
|
331
|
+
kind: "structure",
|
|
332
|
+
source: "structure",
|
|
333
|
+
prior: 0.35,
|
|
334
|
+
evidence: "site has no /about"
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
return candidates;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The site's own voice, when the user has told us what it is.
|
|
341
|
+
*
|
|
342
|
+
* `sitePurpose` and tone reach the planner's context pack already; until now
|
|
343
|
+
* the only pills that used them were the welcome ones, and only as a template.
|
|
344
|
+
*/
|
|
345
|
+
export function contextCandidates(args) {
|
|
346
|
+
const candidates = [];
|
|
347
|
+
const tone = args.siteTone?.trim();
|
|
348
|
+
if (tone && tone.length < 30) {
|
|
349
|
+
candidates.push({
|
|
350
|
+
label: `Rewrite this page to sound more ${tone}`,
|
|
351
|
+
kind: "refine",
|
|
352
|
+
source: "context",
|
|
353
|
+
prior: 0.45,
|
|
354
|
+
evidence: `site tone is "${tone}"`
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return candidates;
|
|
358
|
+
}
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// Ranking
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
/**
|
|
363
|
+
* Score, deduplicate, diversify, cap.
|
|
364
|
+
*
|
|
365
|
+
* The cap is three rather than four, and returning fewer than three — or none —
|
|
366
|
+
* is a valid answer. Three real suggestions beat six of which one works, and an
|
|
367
|
+
* empty row leaves whatever the assistant said as the thing on screen, which is
|
|
368
|
+
* usually the more useful object anyway.
|
|
369
|
+
*/
|
|
370
|
+
export function rank(candidates, ctx) {
|
|
371
|
+
const shown = ctx.shown ?? new Set();
|
|
372
|
+
const clicked = ctx.clicked ?? new Set();
|
|
373
|
+
const limit = ctx.limit ?? SUGGESTION_LIMIT;
|
|
374
|
+
const scored = [];
|
|
375
|
+
const seenIds = new Set();
|
|
376
|
+
for (const candidate of candidates) {
|
|
377
|
+
const label = candidate.label.trim();
|
|
378
|
+
const prompt = (candidate.prompt ?? label).trim();
|
|
379
|
+
const failure = validateSuggestion(prompt, { mode: ctx.mode });
|
|
380
|
+
if (failure)
|
|
381
|
+
continue;
|
|
382
|
+
const id = suggestionId(prompt);
|
|
383
|
+
if (seenIds.has(id))
|
|
384
|
+
continue;
|
|
385
|
+
seenIds.add(id);
|
|
386
|
+
/*
|
|
387
|
+
* Novelty. A pill the user was already offered and did not click is one
|
|
388
|
+
* they have declined once; offering it again every turn is how "Add
|
|
389
|
+
* Testimonials" came to appear on turn 1 and turn 9 of the same session.
|
|
390
|
+
* A clicked pill is dropped outright — it has been acted on, and the page
|
|
391
|
+
* has moved past it.
|
|
392
|
+
*/
|
|
393
|
+
if (clicked.has(id))
|
|
394
|
+
continue;
|
|
395
|
+
const score = shown.has(id) ? candidate.prior - 0.3 : candidate.prior;
|
|
396
|
+
scored.push({
|
|
397
|
+
id,
|
|
398
|
+
label: label.length > MAX_LABEL_CHARS ? `${label.slice(0, MAX_LABEL_CHARS - 1).trimEnd()}…` : label,
|
|
399
|
+
prompt,
|
|
400
|
+
kind: candidate.kind,
|
|
401
|
+
source: candidate.source,
|
|
402
|
+
score,
|
|
403
|
+
evidence: candidate.evidence
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
scored.sort((a, b) => b.score - a.score);
|
|
407
|
+
/*
|
|
408
|
+
* Diversity. Three pills that all say "add a section" are one suggestion
|
|
409
|
+
* wearing three hats — the user's choice is not between them, it is between
|
|
410
|
+
* doing that and doing something else. At most one per kind until every kind
|
|
411
|
+
* has had a turn.
|
|
412
|
+
*/
|
|
413
|
+
const picked = [];
|
|
414
|
+
const usedKinds = new Set();
|
|
415
|
+
for (const suggestion of scored) {
|
|
416
|
+
if (picked.length >= limit)
|
|
417
|
+
break;
|
|
418
|
+
if (usedKinds.has(suggestion.kind))
|
|
419
|
+
continue;
|
|
420
|
+
picked.push(suggestion);
|
|
421
|
+
usedKinds.add(suggestion.kind);
|
|
422
|
+
}
|
|
423
|
+
for (const suggestion of scored) {
|
|
424
|
+
if (picked.length >= limit)
|
|
425
|
+
break;
|
|
426
|
+
if (picked.some((entry) => entry.id === suggestion.id))
|
|
427
|
+
continue;
|
|
428
|
+
picked.push(suggestion);
|
|
429
|
+
}
|
|
430
|
+
return picked;
|
|
431
|
+
}
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// Entry point
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
/**
|
|
436
|
+
* The path's own deterministic fallback, as candidates.
|
|
437
|
+
*
|
|
438
|
+
* Given a slightly lower prior than the model's: when both have an opinion the
|
|
439
|
+
* model read the user's sentence and this did not. It still competes, and on
|
|
440
|
+
* the clarification path — where it is answering a specific question — it
|
|
441
|
+
* routinely wins.
|
|
442
|
+
*/
|
|
443
|
+
export function fallbackCandidates(raw) {
|
|
444
|
+
if (!raw || raw.length === 0)
|
|
445
|
+
return [];
|
|
446
|
+
return raw.map((text) => ({
|
|
447
|
+
label: text.trim(),
|
|
448
|
+
kind: inferKind(text),
|
|
449
|
+
source: "structure",
|
|
450
|
+
prior: 0.7,
|
|
451
|
+
evidence: "deterministic fallback for this path"
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
export function buildSuggestions(ctx) {
|
|
455
|
+
/*
|
|
456
|
+
* A clarification asked a question. The only useful pills are answers to it,
|
|
457
|
+
* which is what the model's suggestions and the path's own fallback are —
|
|
458
|
+
* page gaps and section nudges are follow-ups to a *completed* edit and would
|
|
459
|
+
* change the subject while the question is still on screen.
|
|
460
|
+
*/
|
|
461
|
+
if (ctx.mode === "clarification") {
|
|
462
|
+
return rank([...modelCandidates(ctx.modelSuggestions), ...fallbackCandidates(ctx.fallbackSuggestions)], ctx);
|
|
463
|
+
}
|
|
464
|
+
const candidates = [
|
|
465
|
+
...modelCandidates(ctx.modelSuggestions),
|
|
466
|
+
...fallbackCandidates(ctx.fallbackSuggestions),
|
|
467
|
+
...refinementCandidates(ctx.plan, ctx.page),
|
|
468
|
+
...pageGapCandidates(ctx.page),
|
|
469
|
+
...structureCandidates(ctx.page, ctx.availableSlugs),
|
|
470
|
+
...contextCandidates({ sitePurpose: ctx.sitePurpose, siteTone: ctx.siteTone })
|
|
471
|
+
];
|
|
472
|
+
return rank(candidates, ctx);
|
|
473
|
+
}
|
|
474
|
+
/** The legacy wire shape: what `suggestions: string[]` has always carried. */
|
|
475
|
+
export function toLegacySuggestions(suggestions) {
|
|
476
|
+
return suggestions.map((suggestion) => suggestion.prompt);
|
|
477
|
+
}
|
|
@@ -164,6 +164,30 @@ export declare const pendingApprovalPlanBySession: Map<string, PendingApprovalPl
|
|
|
164
164
|
*/
|
|
165
165
|
export type ImageSourcePreference = "unsplash" | "genai" | "either";
|
|
166
166
|
export declare const imageSourcePreferenceBySession: Map<string, ImageSourcePreference>;
|
|
167
|
+
/**
|
|
168
|
+
* Which suggestion pills this session has already been shown, and which it
|
|
169
|
+
* clicked.
|
|
170
|
+
*
|
|
171
|
+
* A pill offered and not clicked is one the user has declined once. Nothing
|
|
172
|
+
* recorded that, so the same three chips were re-derived from the same page
|
|
173
|
+
* state every turn and "Add Testimonials" appeared on turn 1 and again on turn
|
|
174
|
+
* 9 of the same conversation. The ranker reads these to decay a repeat and to
|
|
175
|
+
* drop a suggestion that has been acted on.
|
|
176
|
+
*
|
|
177
|
+
* Ephemeral, like its neighbours above: a chat session is the right lifetime
|
|
178
|
+
* for "have I already offered you this", and there is nothing here worth
|
|
179
|
+
* carrying across a restart. Capped so a long session cannot grow it without
|
|
180
|
+
* bound.
|
|
181
|
+
*/
|
|
182
|
+
export declare const SUGGESTION_MEMORY_CAP = 60;
|
|
183
|
+
export type SuggestionMemory = {
|
|
184
|
+
shown: string[];
|
|
185
|
+
clicked: string[];
|
|
186
|
+
};
|
|
187
|
+
export declare const suggestionMemoryBySession: Map<string, SuggestionMemory>;
|
|
188
|
+
export declare function getSuggestionMemory(session: string): SuggestionMemory;
|
|
189
|
+
export declare function recordSuggestionsShown(session: string, ids: string[]): void;
|
|
190
|
+
export declare function recordSuggestionClicked(session: string, id: string): void;
|
|
167
191
|
export declare const publishStatusBySession: Map<string, PublishTracker>;
|
|
168
192
|
/**
|
|
169
193
|
* What the adapter behind a session says it can honour, recorded when the
|
|
@@ -99,6 +99,44 @@ export const pendingClarificationBySession = new Map();
|
|
|
99
99
|
export const chatHistoryBySession = new Map();
|
|
100
100
|
export const pendingApprovalPlanBySession = new Map();
|
|
101
101
|
export const imageSourcePreferenceBySession = new Map();
|
|
102
|
+
/**
|
|
103
|
+
* Which suggestion pills this session has already been shown, and which it
|
|
104
|
+
* clicked.
|
|
105
|
+
*
|
|
106
|
+
* A pill offered and not clicked is one the user has declined once. Nothing
|
|
107
|
+
* recorded that, so the same three chips were re-derived from the same page
|
|
108
|
+
* state every turn and "Add Testimonials" appeared on turn 1 and again on turn
|
|
109
|
+
* 9 of the same conversation. The ranker reads these to decay a repeat and to
|
|
110
|
+
* drop a suggestion that has been acted on.
|
|
111
|
+
*
|
|
112
|
+
* Ephemeral, like its neighbours above: a chat session is the right lifetime
|
|
113
|
+
* for "have I already offered you this", and there is nothing here worth
|
|
114
|
+
* carrying across a restart. Capped so a long session cannot grow it without
|
|
115
|
+
* bound.
|
|
116
|
+
*/
|
|
117
|
+
export const SUGGESTION_MEMORY_CAP = 60;
|
|
118
|
+
export const suggestionMemoryBySession = new Map();
|
|
119
|
+
export function getSuggestionMemory(session) {
|
|
120
|
+
return suggestionMemoryBySession.get(session) ?? { shown: [], clicked: [] };
|
|
121
|
+
}
|
|
122
|
+
function rememberSuggestionIds(session, field, ids) {
|
|
123
|
+
if (ids.length === 0)
|
|
124
|
+
return;
|
|
125
|
+
const memory = suggestionMemoryBySession.get(session) ?? { shown: [], clicked: [] };
|
|
126
|
+
// Newest last, deduplicated, oldest evicted first — so a suggestion that
|
|
127
|
+
// scrolled out of memory long ago can legitimately be offered again.
|
|
128
|
+
const merged = [...memory[field].filter((id) => !ids.includes(id)), ...ids];
|
|
129
|
+
suggestionMemoryBySession.set(session, {
|
|
130
|
+
...memory,
|
|
131
|
+
[field]: merged.slice(-SUGGESTION_MEMORY_CAP)
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
export function recordSuggestionsShown(session, ids) {
|
|
135
|
+
rememberSuggestionIds(session, "shown", ids);
|
|
136
|
+
}
|
|
137
|
+
export function recordSuggestionClicked(session, id) {
|
|
138
|
+
rememberSuggestionIds(session, "clicked", [id]);
|
|
139
|
+
}
|
|
102
140
|
export const publishStatusBySession = new Map();
|
|
103
141
|
/**
|
|
104
142
|
* What the adapter behind a session says it can honour, recorded when the
|
|
@@ -178,6 +216,7 @@ export function forgetSession(sessionKey) {
|
|
|
178
216
|
// left behind here would rehydrate into the *next* session with this id.
|
|
179
217
|
discardPendingProposalsForSession(sessionKey);
|
|
180
218
|
imageSourcePreferenceBySession.delete(sessionKey);
|
|
219
|
+
suggestionMemoryBySession.delete(sessionKey);
|
|
181
220
|
publishStatusBySession.delete(sessionKey);
|
|
182
221
|
capabilitiesBySession.delete(sessionKey);
|
|
183
222
|
return existed;
|
|
@@ -1134,7 +1173,12 @@ export function evictStaleEphemeralMaps() {
|
|
|
1134
1173
|
}
|
|
1135
1174
|
}
|
|
1136
1175
|
// Maps without timestamps — cap at EPHEMERAL_MAP_CAP, drop oldest-inserted entries
|
|
1137
|
-
for (const map of [
|
|
1176
|
+
for (const map of [
|
|
1177
|
+
continuationChainBySession,
|
|
1178
|
+
pendingClarificationBySession,
|
|
1179
|
+
imageSourcePreferenceBySession,
|
|
1180
|
+
suggestionMemoryBySession
|
|
1181
|
+
]) {
|
|
1138
1182
|
if (map.size > EPHEMERAL_MAP_CAP) {
|
|
1139
1183
|
const excess = map.size - EPHEMERAL_MAP_CAP;
|
|
1140
1184
|
let dropped = 0;
|
|
@@ -49,6 +49,20 @@ export type ChatTelemetryEntry = {
|
|
|
49
49
|
toolAttempts?: number;
|
|
50
50
|
toolErrorCode?: string;
|
|
51
51
|
correlationId?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Suggestion pills: what was offered on this turn, and whether this turn was
|
|
54
|
+
* itself a click on one.
|
|
55
|
+
*
|
|
56
|
+
* There was no telemetry of any kind on pills before this — `grep suggestion`
|
|
57
|
+
* across this directory returned nothing — so which chips get clicked, which
|
|
58
|
+
* are ignored, and whether a click produced any operations were all unknown.
|
|
59
|
+
* That made "the suggestions should be smarter" an unfalsifiable claim.
|
|
60
|
+
* `suggestionIds` joins to `clickedSuggestionId` on a later turn, and the
|
|
61
|
+
* `opCount` already on that turn says whether the click was worth the click.
|
|
62
|
+
*/
|
|
63
|
+
suggestionIds?: string[];
|
|
64
|
+
suggestionSources?: string[];
|
|
65
|
+
clickedSuggestionId?: string;
|
|
52
66
|
};
|
|
53
67
|
type Logger = {
|
|
54
68
|
info: (payload: Record<string, unknown>, message?: string) => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/orchestrator-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"openai": "^4.87.1",
|
|
23
23
|
"sharp": "^0.35.4",
|
|
24
24
|
"zod": "^4.3.6",
|
|
25
|
-
"@avocadostudio-ai/migration-sdk": "^0.
|
|
26
|
-
"@avocadostudio-ai/shared": "^0.
|
|
25
|
+
"@avocadostudio-ai/migration-sdk": "^0.14.0",
|
|
26
|
+
"@avocadostudio-ai/shared": "^0.14.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|