sygnal 6.2.0 → 6.3.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/CHANGELOG.md +61 -0
- package/README.md +3 -1
- package/dist/ai.cjs.js +36 -0
- package/dist/ai.cjs.js.map +1 -1
- package/dist/ai.esm.js +1 -1
- package/dist/astro/server.cjs.js +3 -3
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +3 -3
- package/dist/astro/server.mjs.map +1 -1
- package/dist/diagnostics.cjs.js +164 -3
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +164 -3
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/agent.md +7 -1
- package/dist/guide/ai-cards.md +319 -0
- package/dist/guide/ai-chat.md +4 -0
- package/dist/guide/ai-compose.md +881 -0
- package/dist/guide/ai-decisions.md +16 -0
- package/dist/guide/http.md +1 -0
- package/dist/index.cjs.js +5695 -347
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +638 -8
- package/dist/index.esm.js +5685 -347
- package/dist/index.esm.js.map +1 -1
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vike/onRenderClient.cjs.js +3 -2
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +3 -2
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/llms.txt +6 -4
- package/package.json +2 -1
- package/src/ai.d.ts +642 -7
- package/src/ai.ts +6 -0
- package/src/collection.ts +2 -1
- package/src/core/instance.ts +6 -3
- package/src/core/markers/lazy.ts +7 -3
- package/src/core/markers/portal.ts +5 -2
- package/src/core/markers/suspense.ts +3 -2
- package/src/core/markers/transition.ts +6 -3
- package/src/core/registry.ts +9 -5
- package/src/cycle/dom/IsolateModule.ts +25 -22
- package/src/extra/ai/chat/behavior.ts +354 -81
- package/src/extra/ai/chat/driver.ts +7 -4
- package/src/extra/ai/compose/agent.ts +55 -0
- package/src/extra/ai/compose/ask.ts +147 -0
- package/src/extra/ai/compose/behavior.ts +183 -0
- package/src/extra/ai/compose/catalog.ts +211 -0
- package/src/extra/ai/compose/composed.ts +61 -0
- package/src/extra/ai/compose/decisions.ts +1649 -0
- package/src/extra/ai/compose/diag.ts +33 -0
- package/src/extra/ai/compose/index.ts +23 -0
- package/src/extra/ai/compose/layout.ts +441 -0
- package/src/extra/ai/compose/llm.ts +1120 -0
- package/src/extra/ai/compose/render.ts +159 -0
- package/src/extra/ai/compose/testing.ts +71 -0
- package/src/extra/ai/compose/types.ts +159 -0
- package/src/extra/ai/decide.ts +97 -1
- package/src/extra/ai/index.ts +6 -1
- package/src/extra/diagnostics/checks/next.ts +86 -2
- package/src/extra/diagnostics/codes.ts +54 -0
- package/src/extra/fetchDriver.ts +58 -27
- package/src/extra/lens.ts +15 -0
- package/src/extra/ssr.ts +3 -3
- package/src/extra/testing.ts +2 -0
- package/src/extra/widget.ts +3 -3
- package/src/index.d.ts +41 -0
- package/src/index.ts +8 -0
- package/src/lazy.ts +3 -2
- package/src/portal.ts +5 -2
- package/src/pragma/index.ts +2 -1
- package/src/suspense.ts +5 -2
- package/src/switchable.ts +2 -1
- package/src/transition.ts +5 -2
- package/src/vike/onRenderClient.ts +3 -2
|
@@ -0,0 +1,1649 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-9 B-1 / B-2 / B-4: `compose.decisions()`, the decision-model backend of `compose` (TypeSafe Jev,
|
|
3
|
+
* Ollama `nimble`, anything `decide()` reaches). It composes a layout from the catalog's parts with
|
|
4
|
+
* choice / noul questions only: the model never writes text. Ported from the research encodings,
|
|
5
|
+
* with their exact wording:
|
|
6
|
+
* - structure: dev-plans/research/ai-compose/structure/composer.mjs (TEXT, 189/210 on Jev);
|
|
7
|
+
* - params, records and edits: dev-plans/research/ai-compose/params-edits/params-edits.mjs (W).
|
|
8
|
+
*
|
|
9
|
+
* Steps (one `decide()` request each, through the HTTP sink):
|
|
10
|
+
* 0. categories (parts with a `category`; `prefilter: true`, or the default 'auto' when the select
|
|
11
|
+
* step would ask about more than PREFILTER_AT parts, PREFILTER_AT_SMALL with profile 'small'):
|
|
12
|
+
* `cat_<id>` per category (B-5: same accuracy at about half the tokens, one more call).
|
|
13
|
+
* 1. select (state: the request alone):
|
|
14
|
+
* use_<part> (omit / use; structure wording, params-edits wording for a part with params); count_<part> (0..maxUses) for a repeatable part;
|
|
15
|
+
* pick_<group> (omit + each member) for a `group` of alternatives;
|
|
16
|
+
* rec_<part>__<n> (n = 1..4; none + records) for a record part: "which <label> does user_request name
|
|
17
|
+
* first / second / …", over the records a lexical prefilter on name / aliases kept;
|
|
18
|
+
* param_<part>__<name> for an enum param or the bins of a number param, with not_stated
|
|
19
|
+
* (= the default); `type: 'text'` / `'list'` params keep their default (LLM-only; a part
|
|
20
|
+
* with a required one and no default is left out, SYG680);
|
|
21
|
+
* row: "side by side?" (a noul gate, when the catalog has the built-in `row`).
|
|
22
|
+
* Nothing chosen: nothing fits (1 call). One part in one possible place: done (1 call).
|
|
23
|
+
* 2. layout (state: the request + the selected elements): parent_<k> over the places `in`
|
|
24
|
+
* allows; with the row gate on, beside_<k> and left_<a>_vs_<b>; first_<a>_vs_<b> per pair;
|
|
25
|
+
* gap_<x>_in_<part> for a part used 2+ times.
|
|
26
|
+
* With 4+ elements that may share rows, also same_<a>_vs_<b> (a noul per pair), so a row longer
|
|
27
|
+
* than 3 (up to the row container's `max`) can be read: one beside answer per element names
|
|
28
|
+
* one neighbour, a chain that average-linkage splits.
|
|
29
|
+
* Reading is code: rows by average-linkage clustering of `beside` (`rowMean`), order by summed
|
|
30
|
+
* pairwise probabilities, pins, required parts, companions, containers present only when filled,
|
|
31
|
+
* `minSlots` unwrap, limits. The result goes through `finish()` (layout.ts), shared with `llm()`.
|
|
32
|
+
*
|
|
33
|
+
* Edits (B-4, params-edits.mjs §3, its wording): with a previous layout that has parts, at most 2 calls.
|
|
34
|
+
* edit (state: the request + current_page, each node described with its record, settings and
|
|
35
|
+
* place): remove_<node> (noul), simpler_<node> (read only under the `simpler` gate),
|
|
36
|
+
* set_<node>__<param> (keep + the other options; read only under the `change` gate), add_<part> /
|
|
37
|
+
* add_g_<group> for the parts not on the page (+ their params, + 2 record slots over the records
|
|
38
|
+
* the request names that aren't on the page), the gates swap / simpler / placed / change, and
|
|
39
|
+
* moved_1 / moved_2 (which node does the request move: slots over the nodes, a lookup).
|
|
40
|
+
* where (only when something moves or is added): where_<node> per mover over not_stated, top,
|
|
41
|
+
* bottom, above:/below:/beside:<node>, into:<container key>:<slot> (the places its `in` allows; a
|
|
42
|
+
* container not on the page is created).
|
|
43
|
+
* Rules: kept nodes keep their keys (the reply says `keyed`: the behavior doesn't re-key); a
|
|
44
|
+
* setting change keeps the key and is never a removal; added nodes get keys the previous layout
|
|
45
|
+
* never had; a replacement (swap + one removed + one added) takes the removed node's place and
|
|
46
|
+
* row unless its where says otherwise; a reference node (another mover's anchor) never moves; a
|
|
47
|
+
* simpler page is never empty; nothing to do at all reads as "nothing fits" (the layout kept).
|
|
48
|
+
* Every choice respects `maxOptions` (Ollama: 25): a larger one is split into chunks that repeat its
|
|
49
|
+
* head options, merged back before reading. `profile: 'small'` (G-755): pairwise order questions in
|
|
50
|
+
* both option orders, near-ties ordered by where the request names the parts.
|
|
51
|
+
*
|
|
52
|
+
* `pending` is plain JSON (the behavior keeps it in state): the step, the prompt, the record
|
|
53
|
+
* values the prefilter kept, the selected items. The questions are rebuilt from it to read a reply,
|
|
54
|
+
* so a reply is checked against the criteria that were sent (a choice outside them: SYG682).
|
|
55
|
+
*
|
|
56
|
+
* Side-effect free (0 B for apps that don't import it).
|
|
57
|
+
*/
|
|
58
|
+
import {decide, choice, noul} from '../decide'
|
|
59
|
+
import {answers as answersFor} from '../answers'
|
|
60
|
+
import {report} from '../../diagnostics/index'
|
|
61
|
+
import {fromTree, toTree, finish} from './layout'
|
|
62
|
+
|
|
63
|
+
const ROOT = 'page'
|
|
64
|
+
const ROOT_SLOT = 'default'
|
|
65
|
+
const ROW = 'row'
|
|
66
|
+
const ROW_MIN = 0.5
|
|
67
|
+
/** rowable elements from which the layout step adds same-row nouls */
|
|
68
|
+
const SAME_ROW = 4
|
|
69
|
+
const sep = '__'
|
|
70
|
+
|
|
71
|
+
// ---------- wording (structure/composer.mjs TEXT and params-edits.mjs W, verbatim) ----------
|
|
72
|
+
export const TEXT = {
|
|
73
|
+
select: 'Is this element one of the things user_request asks to see? Choose use only when user_request names this element or describes what it shows. Choose omit for elements that are merely about the same topic, area or page as what user_request asks for.',
|
|
74
|
+
variant: 'Which of these elements does user_request ask for, by name or by what it shows? Choose omit when it asks for none of them. When it asks for this kind of element without saying which, choose the first one.',
|
|
75
|
+
count: (d: string) => `How many times does user_request want this element on the page: ${d}? Choose 0 when it does not ask for it.`,
|
|
76
|
+
omit: 'None of these elements is needed.',
|
|
77
|
+
category: 'Does user_request ask for anything in this area, by name or by what it shows?',
|
|
78
|
+
row: 'Does user_request explicitly ask for some elements to be placed side by side, next to each other, beside each other or in a row?',
|
|
79
|
+
parent: (d: string) => `Where does this element go: ${d}? Follow user_request when it says where. Otherwise choose the place whose description fits this element best.`,
|
|
80
|
+
beside: (d: string) => `Which element does user_request want side by side with this element, in the same row: ${d}? Choose none unless user_request asks for these elements to be next to each other, beside each other or in a row.`,
|
|
81
|
+
// new (rows of 4+): one `beside` answer per element names one neighbour, so a long row reads as a chain
|
|
82
|
+
same: (a: string, b: string) => `Does user_request want these two elements in the same row, side by side: ${a}; and ${b}? Answer no unless user_request asks for both of them to be next to each other or in one row.`,
|
|
83
|
+
gap: (d: string, n: number, x: string, guidance?: string) => `This page shows this element ${n} times: ${d}. Where does this other element go relative to those copies: ${x}? Follow user_request: it may say where each copy goes (for example at the top and again at the bottom) and what goes between them.${guidance ? ' When user_request does not say: ' + guidance : ''}`,
|
|
84
|
+
left: 'If these two elements are side by side in a row, which one is on the left? Follow user_request: the one it puts on the left, or names first when it lists them side by side, is on the left.',
|
|
85
|
+
first: (guidance?: string) => `Which of these two elements comes first on the page (higher up, or further left in a row)? Follow user_request: an element it wants at the top, first, above or on the left comes first; one it wants below, under, after, at the end or on the right comes later. When user_request does not say, ${guidance ? guidance + ' Otherwise ' : ''}the one user_request mentions first comes first.`,
|
|
86
|
+
}
|
|
87
|
+
const ORDINAL = ['', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth']
|
|
88
|
+
export const W = {
|
|
89
|
+
select: 'Does user_request ask for this element, by name or by what it shows? Every element user_request names must be used, wherever on the page it wants it. Omit elements user_request does not ask for, even when related to the topic.',
|
|
90
|
+
param: (d: string, label: string, ask?: string) => `Consider this element: ${d}. ${ask ?? `Which ${label} does user_request ask for it?`} Choose not_stated when user_request does not mention a ${label}.`,
|
|
91
|
+
notStated: (label: string) => `user_request does not mention a ${label}`,
|
|
92
|
+
recordFamily: (d: string, label: string, names: string[]) => `${d}; one per ${label} that user_request names${names.length ? ` (the ${label}s: ${names.join(', ')})` : ''}`,
|
|
93
|
+
slot: (d: string, label: string, n: number) => `${d}. Which ${label} does user_request name ${ORDINAL[n] ?? `as number ${n}`}? Choose none when user_request names fewer than ${n} ${label}${n > 1 ? 's' : ''} from this list.`,
|
|
94
|
+
slotNone: (label: string, n: number) => `user_request names fewer than ${n} ${label}${n > 1 ? 's' : ''} from this list`,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------- the catalog (normalized form, phase1-contract.md) ----------
|
|
98
|
+
const partIds = (c: any) => Object.keys(c.parts || {})
|
|
99
|
+
const partOf = (c: any, id: string) => c.parts?.[id]
|
|
100
|
+
const containerOf = (c: any, id: string) => c.containers?.[id]
|
|
101
|
+
const hasRows = (c: any) => !!containerOf(c, ROW)
|
|
102
|
+
const paramsOf = (part: any) => part?.params || {}
|
|
103
|
+
const labelOf = (name: string, p: any) => p.label ?? name
|
|
104
|
+
/**
|
|
105
|
+
* a param's kind: the normalized catalog's `kind` (types.ts: 'options' | 'number' | 'text' | 'list' |
|
|
106
|
+
* 'record'); a raw declaration (`options` / `from` / `type`) reads the same
|
|
107
|
+
*/
|
|
108
|
+
const kindOf = (p: any): string => p?.kind ?? (typeof p?.from == 'function' ? 'record' : p?.options ? 'options' : p?.type ?? '')
|
|
109
|
+
/** the record param of a part (at most one) */
|
|
110
|
+
const recordParam = (part: any) => Object.keys(paramsOf(part)).find(k => kindOf(part.params[k]) == 'record')
|
|
111
|
+
/** the options a decision model picks from: an enum's options, a number param's bins */
|
|
112
|
+
const optionsOf = (p: any) => (kindOf(p) == 'number' ? p.bins : kindOf(p) == 'options' ? p.options : null) || {}
|
|
113
|
+
const askable = (p: any) => (kindOf(p) == 'options' || kindOf(p) == 'number') && Object.keys(optionsOf(p)).length > 1
|
|
114
|
+
/** a required param a decision model can't fill (free text with no default): the part is LLM-only */
|
|
115
|
+
export const llmOnly = (part: any) => Object.values(paramsOf(part)).some((p: any) => kindOf(p) != 'record' && !askable(p) && p.required && p.default === undefined)
|
|
116
|
+
const maxUses = (part: any) => Math.max(1, part?.maxUses ?? 1)
|
|
117
|
+
/** record slots per record part: "the first … fourth <label> the request names" (params-edits maxRecords) */
|
|
118
|
+
const RECORD_SLOTS = 4
|
|
119
|
+
const slotNames = (k: any) => Object.keys(k?.slots || {default: null})
|
|
120
|
+
|
|
121
|
+
/** the allowed places of a part or container as 'container:slot' strings */
|
|
122
|
+
function allowed(c: any, entry: any): string[] {
|
|
123
|
+
const out: string[] = []
|
|
124
|
+
for (const d of entry?.in || [ROOT]) {
|
|
125
|
+
if (d === ROOT) out.push(`${ROOT}:${ROOT_SLOT}`)
|
|
126
|
+
else if (d.includes(':')) { if (containerOf(c, d.split(':')[0])) out.push(d) }
|
|
127
|
+
else if (d !== ROW && containerOf(c, d)) for (const s of slotNames(containerOf(c, d))) out.push(`${d}:${s}`)
|
|
128
|
+
}
|
|
129
|
+
return out.length ? out : [`${ROOT}:${ROOT_SLOT}`]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------- records: the lexical prefilter (params-edits.mjs narrowRecords) ----------
|
|
133
|
+
const words = (t: any) => String(t).toLowerCase().replace(/'s\b/g, '').match(/[\p{L}\p{N}]+/gu) || []
|
|
134
|
+
function recordsOf(part: any, rp: string, state: any): any[] {
|
|
135
|
+
let list: any
|
|
136
|
+
try { list = part.params[rp].from(state) } catch (_) { return [] }
|
|
137
|
+
return Array.isArray(list) ? list.filter((r: any) => r && r.value != null) : []
|
|
138
|
+
}
|
|
139
|
+
/** the records whose every name word (or every word of an alias) is in the prompt, best first */
|
|
140
|
+
export function narrowRecords(list: any[], prompt: string, max = 254) {
|
|
141
|
+
const have = new Set(words(prompt))
|
|
142
|
+
return list
|
|
143
|
+
.map(r => ({r, s: Math.max(...[r.name ?? r.value, ...(r.aliases || [])].map(n => { const w = words(n); return w.filter(x => have.has(x)).length / (w.length || 1) }))}))
|
|
144
|
+
.filter(x => x.s >= 1).slice(0, max).map(x => x.r)
|
|
145
|
+
}
|
|
146
|
+
const recordText = (r: any) => r.description ?? r.name ?? String(r.value)
|
|
147
|
+
const recordName = (r: any) => r.name ?? recordText(r).split(/[:,]/)[0]
|
|
148
|
+
|
|
149
|
+
// ---------- choices over a backend's candidate limit (params-edits.mjs chunkedChoice) ----------
|
|
150
|
+
/**
|
|
151
|
+
* A choice with more options than the backend takes (Ollama: 25, `maxOptions`) is split into
|
|
152
|
+
* chunks `<name>__c<k>`, each repeating the `head` options (not_stated / keep / none / omit); every
|
|
153
|
+
* choice goes through here, so every question respects the limit. `unchunk()` merges the answers
|
|
154
|
+
* back into `<name>` before reading.
|
|
155
|
+
*/
|
|
156
|
+
function chunked(qs: any, meta: any, name: string, instructions: string, head: any, options: any, m: any, max: number) {
|
|
157
|
+
const entries = Object.entries(options)
|
|
158
|
+
const room = Math.max(1, max - Object.keys(head).length)
|
|
159
|
+
if (entries.length <= room) { qs[name] = choice(instructions, {...head, ...options}); meta[name] = m; return }
|
|
160
|
+
// even chunks (sizes differ by 1 at most), so none is left with a single option
|
|
161
|
+
const n = Math.ceil(entries.length / room)
|
|
162
|
+
for (let k = 0, i = 0; k < n; k++) {
|
|
163
|
+
const size = Math.floor(entries.length / n) + (k < entries.length % n ? 1 : 0)
|
|
164
|
+
qs[`${name}${sep}c${k}`] = choice(instructions, {...head, ...Object.fromEntries(entries.slice(i, i + size))})
|
|
165
|
+
meta[`${name}${sep}c${k}`] = {...m, base: name, chunk: k, head: Object.keys(head)}
|
|
166
|
+
i += size
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* the chunked answers merged under their base name: the chunk whose non-head answer is the most
|
|
171
|
+
* probable (its own answer object), else the first chunk (all answered a head option)
|
|
172
|
+
*/
|
|
173
|
+
function unchunk(a: any, meta: any) {
|
|
174
|
+
const out = {...a}
|
|
175
|
+
const best: any = {}
|
|
176
|
+
for (const [name, m] of Object.entries<any>(meta)) {
|
|
177
|
+
if (m.base == null || !a[name]) continue
|
|
178
|
+
const x = a[name], b = best[m.base]
|
|
179
|
+
const head = (y: any) => m.head.includes(y.choice)
|
|
180
|
+
const p = (y: any) => y.probabilities?.[y.choice] ?? 0
|
|
181
|
+
if (!b || (head(b) && !head(x)) || (!head(b) && !head(x) && p(x) > p(b))) best[m.base] = x
|
|
182
|
+
}
|
|
183
|
+
return Object.assign(out, best)
|
|
184
|
+
}
|
|
185
|
+
const readChunked = (a: any, name: string) => a[name]
|
|
186
|
+
|
|
187
|
+
// ---------- step 0: categories ----------
|
|
188
|
+
const categoriesOf = (c: any) => {
|
|
189
|
+
const out: string[] = []
|
|
190
|
+
for (const id of partIds(c)) { const k = partOf(c, id).category; if (k != null && !out.includes(k)) out.push(k) }
|
|
191
|
+
return out
|
|
192
|
+
}
|
|
193
|
+
export function categoryStep(c: any, prompt: string) {
|
|
194
|
+
const questions: any = {}, meta: any = {}
|
|
195
|
+
for (const id of categoriesOf(c)) {
|
|
196
|
+
questions[`cat_${id}`] = choice(TEXT.category, {omit: 'Nothing in this area is needed.', use: c.categories?.[id] ?? id})
|
|
197
|
+
meta[`cat_${id}`] = {kind: 'cat', category: id}
|
|
198
|
+
}
|
|
199
|
+
return {state: {user_request: prompt}, questions, meta}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------- step 1: select ----------
|
|
203
|
+
const said = new Set<string>()
|
|
204
|
+
function excluded(id: string) {
|
|
205
|
+
if (said.has(id)) return
|
|
206
|
+
said.add(id)
|
|
207
|
+
dev('SYG680', `part '${id}' has a required text param with no default: a decision model can't fill it, so compose.decisions() never places it (it is LLM-only)`, 'info')
|
|
208
|
+
}
|
|
209
|
+
function dev(code: string, message: string, severity: 'error' | 'warn' | 'info') {
|
|
210
|
+
try { report(code, {component: 'compose.decisions', message, severity}) } catch (e) { queueMicrotask(() => { throw e }) }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** the parts the select step asks about: not required ones, not LLM-only ones, in the chosen categories */
|
|
214
|
+
function selectable(c: any, categories?: string[] | null) {
|
|
215
|
+
return partIds(c).filter(id => {
|
|
216
|
+
const p = partOf(c, id)
|
|
217
|
+
if (p.required) return false
|
|
218
|
+
if (llmOnly(p)) { excluded(id); return false }
|
|
219
|
+
return !categories || p.category == null || categories.includes(p.category)
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The select questions. `records`: the record values the prefilter kept per record part (start()
|
|
225
|
+
* computes it; reply() and fake() rebuild the same questions from it). Returns `meta` per question.
|
|
226
|
+
*/
|
|
227
|
+
export function selectStep(c: any, prompt: string, {categories = null, records = {}, max = 255}: any = {}) {
|
|
228
|
+
const questions: any = {}, meta: any = {}
|
|
229
|
+
const ids = selectable(c, categories).filter(id => !recordParam(partOf(c, id)) || records[id]?.length)
|
|
230
|
+
const seen = new Set<string>()
|
|
231
|
+
for (const id of ids) {
|
|
232
|
+
const part = partOf(c, id)
|
|
233
|
+
const rp = recordParam(part)
|
|
234
|
+
if (part.group != null) {
|
|
235
|
+
if (!seen.has(part.group)) {
|
|
236
|
+
seen.add(part.group)
|
|
237
|
+
const members = ids.filter(m => partOf(c, m).group === part.group)
|
|
238
|
+
const withRecords = members.some(m => recordParam(partOf(c, m)))
|
|
239
|
+
chunked(questions, meta, `pick_${part.group}`, withRecords ? W.select : TEXT.variant, {omit: TEXT.omit},
|
|
240
|
+
Object.fromEntries(members.map(m => {
|
|
241
|
+
const mp = partOf(c, m), mr = recordParam(mp)
|
|
242
|
+
return [m, mr ? W.recordFamily(mp.description, labelOf(mr, mp.params[mr]), records[m].map((r: any) => r.name)) : mp.description]
|
|
243
|
+
})), {kind: 'pick', group: part.group, members}, max)
|
|
244
|
+
}
|
|
245
|
+
} else if (!rp && maxUses(part) > 1) {
|
|
246
|
+
const criteria: any = {}
|
|
247
|
+
for (let n = 1; n <= maxUses(part); n++) criteria[n] = n === 1 ? 'Once.' : `${n} times, in different places on the page.`
|
|
248
|
+
chunked(questions, meta, `count_${id}`, TEXT.count(part.description), {0: 'Not on the page.'}, criteria, {kind: 'count', part: id}, max)
|
|
249
|
+
} else if (!rp) {
|
|
250
|
+
// a part with choice params takes params-edits' select wording: the strict structure wording
|
|
251
|
+
// drops a part the request names by a param value ("refunded orders", "complaints"), which
|
|
252
|
+
// the description leaves out on purpose (Jev, params set x3: 57/60 mixed, 46/60 strict only,
|
|
253
|
+
// 54/60 params wording only; the structure set keeps 64/70: its parts have no params)
|
|
254
|
+
const withParams = Object.values<any>(paramsOf(part)).some(askable)
|
|
255
|
+
questions[`use_${id}`] = choice(withParams ? W.select : TEXT.select, {omit: TEXT.omit, use: part.description})
|
|
256
|
+
meta[`use_${id}`] = {kind: 'use', part: id}
|
|
257
|
+
}
|
|
258
|
+
if (rp) {
|
|
259
|
+
const label = labelOf(rp, part.params[rp])
|
|
260
|
+
const options = Object.fromEntries(records[id].map((r: any) => [String(r.value), r.description]))
|
|
261
|
+
for (let n = 1; n <= RECORD_SLOTS; n++) {
|
|
262
|
+
chunked(questions, meta, `rec_${id}${sep}${n}`, W.slot(part.description, label, n), {none: W.slotNone(label, n)}, options, {kind: 'rec', part: id, n}, max)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// params: of the asked parts and the required ones (read only for the parts that are used)
|
|
267
|
+
const required = partIds(c).filter(id => partOf(c, id).required && !llmOnly(partOf(c, id)))
|
|
268
|
+
for (const id of [...required, ...ids]) {
|
|
269
|
+
const part = partOf(c, id)
|
|
270
|
+
for (const [name, p] of Object.entries<any>(paramsOf(part))) {
|
|
271
|
+
if (!askable(p)) continue
|
|
272
|
+
const label = labelOf(name, p)
|
|
273
|
+
chunked(questions, meta, `param_${id}${sep}${name}`, W.param(part.description, label, p.ask), {not_stated: W.notStated(label)},
|
|
274
|
+
Object.fromEntries(Object.entries(optionsOf(p)).map(([v, d]) => [String(v), d])), {kind: 'param', part: id, param: name}, max)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (hasRows(c)) { questions.row = noul(TEXT.row); meta.row = {kind: 'row'} }
|
|
278
|
+
return {state: {user_request: prompt}, questions, meta}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** the record values the prefilter keeps for each record part, as `{ value, name, description }` */
|
|
282
|
+
function prefilterRecords(c: any, state: any, prompt: string, categories: string[] | null, max: number) {
|
|
283
|
+
const out: any = {}
|
|
284
|
+
for (const id of selectable(c, categories)) {
|
|
285
|
+
const part = partOf(c, id), rp = recordParam(part)
|
|
286
|
+
if (!rp) continue
|
|
287
|
+
const kept = narrowRecords(recordsOf(part, rp, state), prompt, max - 1)
|
|
288
|
+
if (kept.length) out[id] = kept.map(r => ({value: r.value, name: recordName(r), description: recordText(r)}))
|
|
289
|
+
}
|
|
290
|
+
return out
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const useProbability = (a: any) => (!a ? 0 : a.type === 'noul' ? a.noul : a.probabilities?.use ?? (a.choice === 'use' ? 1 : 0))
|
|
294
|
+
const keyPart = (s: string) => s.replace(/[^A-Za-z0-9_]/g, '_')
|
|
295
|
+
|
|
296
|
+
/** an enum / number param's value from its answer (not_stated or nothing: the default; only non-defaults kept) */
|
|
297
|
+
function readParams(c: any, id: string, a: any) {
|
|
298
|
+
const out: any = {}
|
|
299
|
+
for (const [name, p] of Object.entries<any>(paramsOf(partOf(c, id)))) {
|
|
300
|
+
if (!askable(p)) continue
|
|
301
|
+
const x = readChunked(a, `param_${id}${sep}${name}`)
|
|
302
|
+
const v = x?.choice
|
|
303
|
+
if (!v || v === 'not_stated' || !(v in optionsOf(p))) continue
|
|
304
|
+
const value = kindOf(p) == 'number' ? Number(v) : v
|
|
305
|
+
if (String(value) !== String(p.default)) out[name] = value
|
|
306
|
+
}
|
|
307
|
+
return out
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** describes a selected node to the model: description, record, params in force (params-edits describeNode) */
|
|
311
|
+
function describe(c: any, id: string, params: any, record?: any) {
|
|
312
|
+
const part = partOf(c, id)
|
|
313
|
+
let d = part.description
|
|
314
|
+
if (record) d += `, for ${record.description}`
|
|
315
|
+
for (const [name, p] of Object.entries<any>(paramsOf(part))) {
|
|
316
|
+
if (!askable(p)) continue
|
|
317
|
+
const v = params?.[name] ?? p.default
|
|
318
|
+
if (v === undefined) continue
|
|
319
|
+
d += `; ${labelOf(name, p)}: ${optionsOf(p)[v] ?? v}`
|
|
320
|
+
}
|
|
321
|
+
return d
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* select answers → { items: [{ key, part, params?, d }], row, unavailable }. Keys: the part id, `__2`
|
|
326
|
+
* for further copies, `<part>__<record>` for records. Adds required parts and companions; caps at
|
|
327
|
+
* limits.maxParts (keeping the most probable).
|
|
328
|
+
*/
|
|
329
|
+
export function readSelect(c: any, a: any, {categories = null, records = {}, maxParts}: any = {}) {
|
|
330
|
+
const scored: any[] = [] // [{ part, params, record?, p }] in catalog order
|
|
331
|
+
const ids = selectable(c, categories).filter(id => !recordParam(partOf(c, id)) || records[id]?.length)
|
|
332
|
+
const seen = new Set<string>()
|
|
333
|
+
const prob = (x: any) => x?.probabilities?.[x.choice] ?? 1
|
|
334
|
+
const recordsFor = (id: string, atLeastOne: boolean) => {
|
|
335
|
+
const out: any[] = []
|
|
336
|
+
for (let n = 1; n <= RECORD_SLOTS; n++) {
|
|
337
|
+
const x = a[`rec_${id}${sep}${n}`]
|
|
338
|
+
if (!x) break
|
|
339
|
+
// the group choice already said "these records": slot 1 is the best record even when none wins
|
|
340
|
+
const best = Object.entries<any>(x.probabilities || {}).filter(([k]) => k !== 'none').sort((u, v) => v[1] - u[1])[0]
|
|
341
|
+
const v = n === 1 && atLeastOne && x.choice === 'none' && best ? best[0] : x.choice
|
|
342
|
+
if (v === 'none' || v == null) break
|
|
343
|
+
const r = records[id].find((r: any) => String(r.value) === v)
|
|
344
|
+
if (r && !out.some(o => o.r === r)) out.push({r, p: v === x.choice ? prob(x) : best[1]})
|
|
345
|
+
}
|
|
346
|
+
return out
|
|
347
|
+
}
|
|
348
|
+
const add = (id: string, p: number) => {
|
|
349
|
+
const rp = recordParam(partOf(c, id))
|
|
350
|
+
if (!rp) { scored.push({part: id, params: readParams(c, id, a), p}); return }
|
|
351
|
+
for (const {r, p: q} of recordsFor(id, partOf(c, id).group != null)) scored.push({part: id, params: {...readParams(c, id, a), [rp]: r.value}, record: r, p: q})
|
|
352
|
+
}
|
|
353
|
+
for (const id of ids) {
|
|
354
|
+
const part = partOf(c, id)
|
|
355
|
+
if (part.group != null) {
|
|
356
|
+
if (seen.has(part.group)) continue
|
|
357
|
+
seen.add(part.group)
|
|
358
|
+
const x = a[`pick_${part.group}`]
|
|
359
|
+
if (x && x.choice !== 'omit' && partOf(c, x.choice)?.group === part.group) add(x.choice, prob(x))
|
|
360
|
+
} else if (recordParam(part)) add(id, 1)
|
|
361
|
+
else if (maxUses(part) > 1) {
|
|
362
|
+
const x = a[`count_${id}`]
|
|
363
|
+
const n = Math.min(maxUses(part), Number(x?.choice ?? 0) || 0)
|
|
364
|
+
for (let i = 0; i < n; i++) add(id, prob(x))
|
|
365
|
+
} else {
|
|
366
|
+
const p = useProbability(a[`use_${id}`])
|
|
367
|
+
if (p >= 0.5) add(id, p)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!scored.length) return {items: [], row: false, unavailable: true}
|
|
371
|
+
const max = Math.min(c.limits?.maxParts ?? 12, maxParts ?? Infinity)
|
|
372
|
+
let list = scored
|
|
373
|
+
if (list.length > max) {
|
|
374
|
+
const keep = new Set([...list].sort((x, y) => y.p - x.p).slice(0, max))
|
|
375
|
+
list = list.filter(x => keep.has(x))
|
|
376
|
+
}
|
|
377
|
+
const required = partIds(c).filter(id => partOf(c, id).required && !llmOnly(partOf(c, id))).map(id => ({part: id, params: readParams(c, id, a)}))
|
|
378
|
+
list = [...required, ...list]
|
|
379
|
+
for (const x of [...list]) {
|
|
380
|
+
for (const id of partOf(c, x.part)?.companions || []) if (partOf(c, id) && !list.some(y => y.part === id)) list.push({part: id, params: {}})
|
|
381
|
+
}
|
|
382
|
+
// keys
|
|
383
|
+
const used = new Set<string>(), count: any = {}
|
|
384
|
+
const items = list.map(x => {
|
|
385
|
+
let key = x.record ? `${x.part}${sep}${keyPart(String(x.record.value))}` : x.part
|
|
386
|
+
if (!x.record) { count[x.part] = (count[x.part] || 0) + 1; if (count[x.part] > 1) key = `${x.part}${sep}${count[x.part]}` }
|
|
387
|
+
for (let i = 2; used.has(key); i++) key = `${key}_${i}`
|
|
388
|
+
used.add(key)
|
|
389
|
+
const params = Object.keys(x.params || {}).length ? x.params : undefined
|
|
390
|
+
return {key, part: x.part, ...(params && {params}), ...(x.record && {record: true}), d: describe(c, x.part, x.params, x.record)}
|
|
391
|
+
})
|
|
392
|
+
return {items, row: !!a.row && (a.row.noul ?? 0) >= 0.5, unavailable: false}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------- step 2: layout ----------
|
|
396
|
+
const copyOf = (items: any[], key: string) => {
|
|
397
|
+
const it = items.find(i => i.key === key)
|
|
398
|
+
if (!it || it.record) return 1
|
|
399
|
+
return items.filter(i => i.part === it.part && !i.record).findIndex(i => i.key === key) + 1
|
|
400
|
+
}
|
|
401
|
+
const multiParts = (items: any[], keys: string[]) => new Set(keys.map(k => items.find(i => i.key === k)).filter(i => i && !i.record && copyOf(items, i.key) > 1).map(i => i.part))
|
|
402
|
+
|
|
403
|
+
/** everything the layout step needs to know about the selection */
|
|
404
|
+
export function plan(c: any, sel: any) {
|
|
405
|
+
const items = sel.items
|
|
406
|
+
const nodes: string[] = items.map((i: any) => i.key)
|
|
407
|
+
const containers: string[] = []
|
|
408
|
+
const entry = (k: string) => containerOf(c, k) && !items.some((i: any) => i.key === k) ? containerOf(c, k) : partOf(c, items.find((i: any) => i.key === k)?.part)
|
|
409
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
410
|
+
for (const d of allowed(c, entry(nodes[i]))) {
|
|
411
|
+
const k = d.split(':')[0]
|
|
412
|
+
if (k !== ROOT && !nodes.includes(k)) { nodes.push(k); containers.push(k) }
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const dests: any = {}
|
|
416
|
+
for (const k of nodes) {
|
|
417
|
+
// a container never goes into a container that accepts only parts (`accepts: 'leaves'`)
|
|
418
|
+
const box = !items.some((i: any) => i.key === k)
|
|
419
|
+
dests[k] = allowed(c, entry(k)).filter(d => { const x = d.split(':')[0]; return x !== k && (x === ROOT || nodes.includes(x)) && !(box && containerOf(c, x)?.accepts === 'leaves') })
|
|
420
|
+
if (!dests[k].length) dests[k] = [`${ROOT}:${ROOT_SLOT}`]
|
|
421
|
+
}
|
|
422
|
+
const leaves: string[] = items.map((i: any) => i.key)
|
|
423
|
+
const rowable = sel.row && hasRows(c)
|
|
424
|
+
? leaves.filter(k => dests[k].some((d: string) => { const x = d.split(':')[0]; return x === ROOT || !containerOf(c, x)?.arranged }))
|
|
425
|
+
: []
|
|
426
|
+
return {nodes, containers, dests, leaves, rowable}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const pinOf = (c: any, items: any[], key: string) => partOf(c, items.find(i => i.key === key)?.part)?.pin
|
|
430
|
+
const describeCopy = (items: any[], key: string) => {
|
|
431
|
+
const it = items.find(i => i.key === key)
|
|
432
|
+
const n = copyOf(items, key)
|
|
433
|
+
return n > 1 ? `${it.d} (copy ${n})` : it.d
|
|
434
|
+
}
|
|
435
|
+
function destLabel(c: any, dest: string) {
|
|
436
|
+
const [k, slot] = dest.split(':')
|
|
437
|
+
if (k === ROOT) return `${containerOf(c, ROOT)?.description ?? c.description ?? 'The page'}, at the top level (not inside any section, tab or group)`
|
|
438
|
+
const x = containerOf(c, k)
|
|
439
|
+
return x?.slots?.[slot] || x?.description || k
|
|
440
|
+
}
|
|
441
|
+
const needsLayout = (p: any) => p.nodes.length > 1 && (p.leaves.length > 1 || p.nodes.some((k: string) => p.dests[k].length > 1))
|
|
442
|
+
|
|
443
|
+
export function layoutStep(c: any, prompt: string, sel: any, max = 255, debias = false) {
|
|
444
|
+
const p = plan(c, sel)
|
|
445
|
+
const items = sel.items
|
|
446
|
+
const questions: any = {}, meta: any = {}
|
|
447
|
+
const label = (k: string) => (items.some((i: any) => i.key === k) ? describeCopy(items, k) : containerOf(c, k)?.description ?? k)
|
|
448
|
+
for (const k of p.nodes) {
|
|
449
|
+
if (p.dests[k].length < 2) continue
|
|
450
|
+
chunked(questions, meta, `parent_${k}`, TEXT.parent(label(k)), {}, Object.fromEntries(p.dests[k].map((d: string) => [d, destLabel(c, d)])), {kind: 'parent', key: k}, max)
|
|
451
|
+
}
|
|
452
|
+
if (p.rowable.length > 1) {
|
|
453
|
+
for (const k of p.rowable) {
|
|
454
|
+
chunked(questions, meta, `beside_${k}`, TEXT.beside(label(k)), {none: 'No element: this one is not side by side with another.'},
|
|
455
|
+
Object.fromEntries(p.rowable.filter((o: string) => o !== k).map((o: string) => [o, label(o)])), {kind: 'beside', key: k}, max)
|
|
456
|
+
}
|
|
457
|
+
// 4+ elements that may share rows: also a same-row noul per pair, so rows longer than 3 (up to
|
|
458
|
+
// the row container's `max`) can be read (beside answers alone make a chain that average-linkage splits)
|
|
459
|
+
if (p.rowable.length >= SAME_ROW) p.rowable.forEach((a: string, i: number) => p.rowable.slice(i + 1).forEach((b: string) => {
|
|
460
|
+
questions[`same_${a}_vs_${b}`] = noul(TEXT.same(label(a), label(b)))
|
|
461
|
+
meta[`same_${a}_vs_${b}`] = {kind: 'same', a, b}
|
|
462
|
+
}))
|
|
463
|
+
p.rowable.forEach((a: string, i: number) => p.rowable.slice(i + 1).forEach((b: string) => {
|
|
464
|
+
for (const [x, y] of debias ? [[a, b], [b, a]] : [[a, b]]) {
|
|
465
|
+
questions[`left_${x}_vs_${y}`] = choice(TEXT.left, {[x]: label(x), [y]: label(y)})
|
|
466
|
+
meta[`left_${x}_vs_${y}`] = {kind: 'left', a: x, b: y}
|
|
467
|
+
}
|
|
468
|
+
}))
|
|
469
|
+
}
|
|
470
|
+
const free = p.leaves.filter(k => !pinOf(c, items, k))
|
|
471
|
+
const multi = multiParts(items, free)
|
|
472
|
+
const single = free.filter(k => !multi.has(items.find((i: any) => i.key === k).part))
|
|
473
|
+
// debias: each pair is asked in both orders too (a small model favours the first option)
|
|
474
|
+
single.forEach((a, i) => single.slice(i + 1).forEach(b => {
|
|
475
|
+
for (const [x, y] of debias ? [[a, b], [b, a]] : [[a, b]]) {
|
|
476
|
+
questions[`first_${x}_vs_${y}`] = choice(TEXT.first(c.guidance), {[x]: label(x), [y]: label(y)})
|
|
477
|
+
meta[`first_${x}_vs_${y}`] = {kind: 'first', a: x, b: y}
|
|
478
|
+
}
|
|
479
|
+
}))
|
|
480
|
+
for (const id of multi) {
|
|
481
|
+
const n = free.filter(k => items.find((i: any) => i.key === k).part === id).length
|
|
482
|
+
for (const x of single) {
|
|
483
|
+
const criteria: any = {0: `Above all ${n} copies of it (before the first one).`}
|
|
484
|
+
for (let g = 1; g < n; g++) criteria[g] = `Between copy ${g} and copy ${g + 1} of it.`
|
|
485
|
+
criteria[n] = `Below all ${n} copies of it (after the last one).`
|
|
486
|
+
chunked(questions, meta, `gap_${x}_in_${id}`, TEXT.gap(partOf(c, id).description, n, label(x), c.guidance), {}, criteria, {kind: 'gap', key: x, part: id}, max)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const state = {user_request: prompt, selected_elements: p.leaves.map(k => ({id: k, content: describeCopy(items, k)}))}
|
|
490
|
+
return {state, questions, meta, plan: p}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* profile 'small': where the request first names each element (its name: the description before
|
|
495
|
+
* '(' or ':', or the part id's words; longer names claim their words first); Infinity when it
|
|
496
|
+
* doesn't. A tie-breaker for pairwise order, which a small model answers by option position.
|
|
497
|
+
*/
|
|
498
|
+
export function mentions(c: any, items: any[], prompt: string): Record<string, number> {
|
|
499
|
+
const text = String(prompt).toLowerCase()
|
|
500
|
+
const claimed = new Array(text.length).fill(false)
|
|
501
|
+
const pos: any = {}
|
|
502
|
+
const phrases: Array<[string, string]> = []
|
|
503
|
+
for (const it of items) {
|
|
504
|
+
pos[it.key] = Infinity
|
|
505
|
+
if (it.record) continue
|
|
506
|
+
const head = String(partOf(c, it.part)?.description ?? '').split(/[(:;,]/)[0].trim().toLowerCase()
|
|
507
|
+
const id = it.part.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase()
|
|
508
|
+
for (const ph of new Set([head, id])) if (ph) phrases.push([ph, it.key])
|
|
509
|
+
}
|
|
510
|
+
phrases.sort((x, y) => y[0].length - x[0].length)
|
|
511
|
+
for (const [ph, key] of phrases) {
|
|
512
|
+
if (pos[key] !== Infinity) continue
|
|
513
|
+
const re = new RegExp(`\\b${ph.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}s?\\b`, 'g')
|
|
514
|
+
for (let m; (m = re.exec(text));) {
|
|
515
|
+
if (claimed.slice(m.index, m.index + m[0].length).some(Boolean)) continue
|
|
516
|
+
for (let i = m.index; i < m.index + m[0].length; i++) claimed[i] = true
|
|
517
|
+
pos[key] = m.index
|
|
518
|
+
break
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return pos
|
|
522
|
+
}
|
|
523
|
+
/** profile 'small': a pair whose mean answers differ by less than this goes by the mention order */
|
|
524
|
+
const TIE = 0.4
|
|
525
|
+
/** a pair's [x, y] win shares, a near-tie decided by where the request names them (`pos`) */
|
|
526
|
+
function pairWins(px: number, py: number, x: string, y: string, pos: Record<string, number> | null): [number, number] {
|
|
527
|
+
if (!pos || Math.abs(px - py) >= TIE || pos[x] === pos[y]) return [px, py]
|
|
528
|
+
return pos[x] < pos[y] ? [1, 0] : [0, 1]
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** pairwise answers → rank per leaf (0 = first): summed win probabilities; copies go into the gaps chosen */
|
|
532
|
+
function ranks(items: any[], leaves: string[], a: any, pos: Record<string, number> | null = null) {
|
|
533
|
+
const partKey = (k: string) => items.find(i => i.key === k).part
|
|
534
|
+
const multi = multiParts(items, leaves)
|
|
535
|
+
const single = leaves.filter(k => !multi.has(partKey(k)))
|
|
536
|
+
const wins: any = Object.fromEntries(single.map(k => [k, 0]))
|
|
537
|
+
single.forEach((x, i) => single.slice(i + 1).forEach(y => {
|
|
538
|
+
// the pair asked once, or in both orders (debias): the mean
|
|
539
|
+
const fs = [a[`first_${x}_vs_${y}`]?.probabilities, a[`first_${y}_vs_${x}`]?.probabilities].filter(Boolean)
|
|
540
|
+
if (!fs.length) return
|
|
541
|
+
const mean = (k: string) => fs.reduce((u, f) => u + (f[k] ?? 0), 0) / fs.length
|
|
542
|
+
const [wx, wy] = pairWins(mean(x), mean(y), x, y, pos)
|
|
543
|
+
wins[x] += wx
|
|
544
|
+
wins[y] += wy
|
|
545
|
+
}))
|
|
546
|
+
const sorted = [...single].sort((x, y) => wins[y] - wins[x] || single.indexOf(x) - single.indexOf(y))
|
|
547
|
+
const score: any = Object.fromEntries(sorted.map((k, i) => [k, i]))
|
|
548
|
+
for (const id of multi) {
|
|
549
|
+
const copies = leaves.filter(k => partKey(k) === id).sort((x, y) => copyOf(items, x) - copyOf(items, y))
|
|
550
|
+
const gap = (x: string) => {
|
|
551
|
+
const pr = a[`gap_${x}_in_${id}`]?.probabilities
|
|
552
|
+
return pr ? Object.entries<any>(pr).reduce((s, [g, q]) => s + Number(g) * q, 0) : copies.length
|
|
553
|
+
}
|
|
554
|
+
copies.forEach((k, i) => {
|
|
555
|
+
const before = sorted.filter(x => gap(x) < i + 0.5)
|
|
556
|
+
score[k] = (before.length ? Math.max(...before.map(x => score[x])) : -1) + 0.5 + i * 0.01
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
const out = [...leaves].sort((x, y) => score[x] - score[y] || leaves.indexOf(x) - leaves.indexOf(y))
|
|
560
|
+
return Object.fromEntries(out.map((k, i) => [k, i]))
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** layout answers → the composition as a Tree (contract format), before `finish()` */
|
|
564
|
+
export function readLayout(c: any, sel: any, a: any, {rowMean = 0.25, prompt = null}: any = {}): any[] {
|
|
565
|
+
const p = plan(c, sel)
|
|
566
|
+
const items = sel.items
|
|
567
|
+
const itemOf = (k: string) => items.find((i: any) => i.key === k)
|
|
568
|
+
const parent: any = {}
|
|
569
|
+
for (const k of p.nodes) {
|
|
570
|
+
const x = a[`parent_${k}`]?.choice
|
|
571
|
+
parent[k] = p.dests[k].includes(x) ? x : p.dests[k][0]
|
|
572
|
+
}
|
|
573
|
+
// cycles → the root
|
|
574
|
+
const nodeOf = (dest: string) => dest.split(':')[0]
|
|
575
|
+
for (const k of p.nodes) {
|
|
576
|
+
const seen = new Set([k])
|
|
577
|
+
let x = nodeOf(parent[k])
|
|
578
|
+
while (x !== ROOT) {
|
|
579
|
+
if (seen.has(x)) { parent[k] = `${ROOT}:${ROOT_SLOT}`; break }
|
|
580
|
+
seen.add(x)
|
|
581
|
+
x = nodeOf(parent[x])
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const free = p.leaves.filter(k => !pinOf(c, items, k))
|
|
585
|
+
const pos = prompt != null ? mentions(c, items, prompt) : null
|
|
586
|
+
const fr = ranks(items, free, a, pos)
|
|
587
|
+
const rank: any = {}
|
|
588
|
+
for (const k of p.leaves) {
|
|
589
|
+
const pin = pinOf(c, items, k)
|
|
590
|
+
rank[k] = pin === 'start' ? -1000 + p.leaves.indexOf(k) : pin === 'end' ? 1000 + p.leaves.indexOf(k) : fr[k]
|
|
591
|
+
}
|
|
592
|
+
const tree: any = {[ROOT]: {part: ROOT, slots: {[ROOT_SLOT]: []}}}
|
|
593
|
+
for (const k of p.nodes) {
|
|
594
|
+
const it = itemOf(k)
|
|
595
|
+
tree[k] = it ? {part: it.part, params: it.params} : {part: k, slots: Object.fromEntries(slotNames(containerOf(c, k)).map(s => [s, []]))}
|
|
596
|
+
}
|
|
597
|
+
for (const k of p.nodes) { const [x, s] = parent[k].split(':'); tree[x].slots[s].push(k) }
|
|
598
|
+
// drop empty containers (repeat: a container may hold only empty containers)
|
|
599
|
+
for (let dropped = true; dropped;) {
|
|
600
|
+
dropped = false
|
|
601
|
+
for (const k of p.containers) {
|
|
602
|
+
if (!tree[k] || Object.values<any>(tree[k].slots).some(l => l.length)) continue
|
|
603
|
+
const [x, s] = parent[k].split(':')
|
|
604
|
+
tree[x].slots[s] = tree[x].slots[s].filter((y: string) => y !== k)
|
|
605
|
+
delete tree[k]
|
|
606
|
+
dropped = true
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// unwrap containers with fewer non-empty slots than minSlots
|
|
610
|
+
for (const k of p.containers) {
|
|
611
|
+
const min = containerOf(c, k)?.minSlots
|
|
612
|
+
if (!tree[k] || !min) continue
|
|
613
|
+
const full = Object.values<any>(tree[k].slots).filter(l => l.length)
|
|
614
|
+
if (full.length >= min) continue
|
|
615
|
+
const [x, s] = parent[k].split(':')
|
|
616
|
+
const kids = full.flat()
|
|
617
|
+
for (const y of kids) parent[y] = `${x}:${s}`
|
|
618
|
+
tree[x].slots[s] = tree[x].slots[s].filter((y: string) => y !== k).concat(kids)
|
|
619
|
+
delete tree[k]
|
|
620
|
+
}
|
|
621
|
+
const rankOf = (k: string): number => {
|
|
622
|
+
if (k in rank) return rank[k]
|
|
623
|
+
const kids = tree[k]?.slots ? Object.values<any>(tree[k].slots).flat() : []
|
|
624
|
+
return kids.length ? Math.min(...kids.map(rankOf)) : Infinity
|
|
625
|
+
}
|
|
626
|
+
// rows: average-linkage clustering of `beside` among the siblings of one non-arranged slot
|
|
627
|
+
let rowN = 0
|
|
628
|
+
if (p.rowable.length > 1) {
|
|
629
|
+
const up: any = {}
|
|
630
|
+
const find = (x: string): string => (up[x] === undefined || up[x] === x ? x : (up[x] = find(up[x])))
|
|
631
|
+
const pt = (x: string, y: string) => {
|
|
632
|
+
const beside = Math.max(a[`beside_${x}`]?.probabilities?.[y] ?? 0, a[`beside_${y}`]?.probabilities?.[x] ?? 0)
|
|
633
|
+
const same = a[`same_${x}_vs_${y}`] ?? a[`same_${y}_vs_${x}`]
|
|
634
|
+
return same ? (beside + (same.noul ?? 0)) / 2 : beside
|
|
635
|
+
}
|
|
636
|
+
const rowMax = containerOf(c, ROW)?.max ?? Infinity
|
|
637
|
+
const bySlot = new Map<string, string[]>()
|
|
638
|
+
for (const k of p.rowable) if (tree[k]) { if (!bySlot.has(parent[k])) bySlot.set(parent[k], []); bySlot.get(parent[k])!.push(k) }
|
|
639
|
+
for (const members of bySlot.values()) {
|
|
640
|
+
const groups = members.map(k => [k])
|
|
641
|
+
for (;;) {
|
|
642
|
+
let best: any = null
|
|
643
|
+
groups.forEach((g, i) => groups.slice(i + 1).forEach((h, j) => {
|
|
644
|
+
const all = g.flatMap(x => h.map(y => pt(x, y)))
|
|
645
|
+
const m = all.reduce((u, v) => u + v, 0) / all.length
|
|
646
|
+
// one confident link and enough support overall (beside answers are sparse)
|
|
647
|
+
// and a row never grows past the row container's `max` (the built-in row: 4)
|
|
648
|
+
if (g.length + h.length <= rowMax && Math.max(...all) >= ROW_MIN && m >= rowMean && (!best || m > best.m)) best = {m, i, j: i + 1 + j}
|
|
649
|
+
}))
|
|
650
|
+
if (!best) break
|
|
651
|
+
groups[best.i] = groups[best.i].concat(groups[best.j])
|
|
652
|
+
groups.splice(best.j, 1)
|
|
653
|
+
}
|
|
654
|
+
for (const g of groups) for (const x of g.slice(1)) up[find(x)] = find(g[0])
|
|
655
|
+
}
|
|
656
|
+
for (const [id, t] of Object.entries<any>(tree)) {
|
|
657
|
+
if (!t.slots || (id !== ROOT && containerOf(c, t.part)?.arranged)) continue
|
|
658
|
+
for (const [s, list] of Object.entries<any>(t.slots)) {
|
|
659
|
+
const groups = new Map<string, string[]>()
|
|
660
|
+
for (const x of list) if (p.rowable.includes(x)) { const r = find(x); if (!groups.has(r)) groups.set(r, []); groups.get(r)!.push(x) }
|
|
661
|
+
let next = [...list]
|
|
662
|
+
for (const members of groups.values()) {
|
|
663
|
+
if (members.length < 2) continue
|
|
664
|
+
const rowKey = `row#${++rowN}`
|
|
665
|
+
tree[rowKey] = {part: ROW, slots: {default: members}}
|
|
666
|
+
next = next.filter(x => !members.includes(x)).concat(rowKey)
|
|
667
|
+
}
|
|
668
|
+
t.slots[s] = next
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// order every slot; inside a row, left/right answers win
|
|
673
|
+
const leftWins = (k: string, members: string[]) => members.reduce((w, o) => {
|
|
674
|
+
if (o === k) return w
|
|
675
|
+
const prs = [a[`left_${k}_vs_${o}`]?.probabilities, a[`left_${o}_vs_${k}`]?.probabilities].filter(Boolean)
|
|
676
|
+
if (!prs.length) return w + (rankOf(k) < rankOf(o) ? 1 : 0)
|
|
677
|
+
const mean = (x: string) => prs.reduce((u: number, pr: any) => u + (pr[x] ?? 0), 0) / prs.length
|
|
678
|
+
return w + pairWins(mean(k), mean(o), k, o, pos)[0]
|
|
679
|
+
}, 0)
|
|
680
|
+
for (const t of Object.values<any>(tree)) {
|
|
681
|
+
if (!t.slots) continue
|
|
682
|
+
for (const s of Object.keys(t.slots)) {
|
|
683
|
+
if (t.part === ROW) {
|
|
684
|
+
const m = t.slots[s]
|
|
685
|
+
const w = Object.fromEntries(m.map((k: string) => [k, leftWins(k, m)]))
|
|
686
|
+
m.sort((x: string, y: string) => w[y] - w[x] || rankOf(x) - rankOf(y))
|
|
687
|
+
} else t.slots[s].sort((x: string, y: string) => rankOf(x) - rankOf(y))
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return toContractTree(c, tree, c.limits?.maxDepth ?? 4)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** the internal tree → the contract's Tree; a container deeper than maxDepth moves to the root */
|
|
694
|
+
function toContractTree(c: any, tree: any, maxDepth: number): any[] {
|
|
695
|
+
const overflow: string[] = []
|
|
696
|
+
const visit = (k: string, depth: number): any => {
|
|
697
|
+
const t = tree[k]
|
|
698
|
+
if (!t.slots) return {part: t.part, ...(t.params && {params: t.params})}
|
|
699
|
+
const kids: any = {}
|
|
700
|
+
for (const [s, list] of Object.entries<any>(t.slots)) {
|
|
701
|
+
const out: any[] = []
|
|
702
|
+
for (const x of list) {
|
|
703
|
+
if (depth + 1 > maxDepth && tree[x].slots) { overflow.push(x); continue }
|
|
704
|
+
out.push(visit(x, depth + 1))
|
|
705
|
+
}
|
|
706
|
+
if (out.length) kids[s] = out
|
|
707
|
+
}
|
|
708
|
+
const named = slotNames(t.part === ROW ? null : containerOf(c, t.part))
|
|
709
|
+
return {part: t.part, children: named.length === 1 && named[0] === ROOT_SLOT ? kids[ROOT_SLOT] || [] : kids}
|
|
710
|
+
}
|
|
711
|
+
const root = visit(ROOT, 0).children
|
|
712
|
+
for (const x of overflow) root.push(visit(x, 1))
|
|
713
|
+
return root
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ---------- replies: validation (SYG682) ----------
|
|
717
|
+
function validate(questions: any, a: any) {
|
|
718
|
+
const out: any = {}
|
|
719
|
+
let bad = 0
|
|
720
|
+
for (const [name, x] of Object.entries<any>(a || {})) {
|
|
721
|
+
const q = questions[name]
|
|
722
|
+
if (!q || !x) continue
|
|
723
|
+
if (q.type === 'choice' && !(String(x.choice) in q.criteria)) {
|
|
724
|
+
dev('SYG682', `the answer to '${name}' is ${JSON.stringify(x.choice)}, not one of its options (${Object.keys(q.criteria).join(', ')}): ignored`, 'warn')
|
|
725
|
+
bad++
|
|
726
|
+
continue
|
|
727
|
+
}
|
|
728
|
+
out[name] = q.type === 'choice' ? {...x, choice: String(x.choice)} : x
|
|
729
|
+
}
|
|
730
|
+
return {answers: out, bad}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ---------- edits (B-4): params-edits.mjs editQuestions / readEdit / editWhereQuestions / applyEdit ----------
|
|
734
|
+
/**
|
|
735
|
+
* The edit wording, verbatim from params-edits.mjs W (66/66 tuned, 43/48 held-out on Jev);
|
|
736
|
+
* `into` (a container slot) is new: the research's edits were on flat pages with rows.
|
|
737
|
+
*/
|
|
738
|
+
export const EDIT = {
|
|
739
|
+
remove: (d: string) => `Does user_request ask to take this element off the page (remove, delete, hide or drop it, or swap it for a different element)? The element: ${d}. Changing its settings (time range, filter, category, sort, number of rows) keeps it on the page. Answer no for elements user_request does not mention.`,
|
|
740
|
+
simplerGate: 'Does user_request ask to make the page simpler, shorter, cleaner or less busy, without naming what to remove?',
|
|
741
|
+
simpler: (d: string) => `This element is on the current page. To make the page simpler, would you remove it as secondary to the page's main purpose? Keep the most important elements. The element: ${d}`,
|
|
742
|
+
placeGate: 'Does user_request say where an element on the current page should go (at the top, at the bottom, first, last, above, below, next to another element)?',
|
|
743
|
+
changeGate: (labels: string[]) => `Does user_request ask to change a setting of an element already on the page (such as its ${labels.join(', ')})?`,
|
|
744
|
+
moveSlot: (n: number) => `Which element on the current page does user_request ask to move to another place (to the top, the bottom, above, below or next to another element)${n > 1 ? ', other than the ones already named' : ''}? ${n > 1 ? `Answer the ${ORDINAL[n] ?? n}` : 'Answer the first'} such element; an element named only as the reference point (the X in "next to X" or "below X") is not moved. Choose none when user_request asks to move fewer than ${n} element${n > 1 ? 's' : ''}.`,
|
|
745
|
+
moveSlotNone: (n: number) => `user_request asks to move fewer than ${n} element${n > 1 ? 's' : ''} already on the page`,
|
|
746
|
+
add: 'This element is not on the current page. Does user_request ask to add it or show it, by name or by what it shows? Answer omit when user_request does not ask for it.',
|
|
747
|
+
addSlot: (d: string, label: string, n: number) => `${d}. Which ${label} does user_request ask to add to the page ${ORDINAL[n] ?? `as number ${n}`}? Choose none when user_request asks to add fewer than ${n} ${label}${n > 1 ? 's' : ''} from this list.`,
|
|
748
|
+
swap: 'Does user_request ask to replace or swap one element for a different element (instead of, in place of, swap X for Y)?',
|
|
749
|
+
setParam: (d: string, label: string) => `This element is on the current page: ${d}. Does user_request ask to change its ${label}? Choose keep unless user_request asks for a different ${label}.`,
|
|
750
|
+
keepParam: (label: string, cur: string) => `keep the current ${label}: ${cur}`,
|
|
751
|
+
where: (d: string, added: boolean) => `${added ? 'This element is being added to the page' : 'This element is being moved'}: ${d}. Where does user_request want it?${added ? ' Choose not_stated when user_request does not say where.' : ''}`,
|
|
752
|
+
inPlace: (d: string) => `user_request does not say where: it takes the place of ${d}`,
|
|
753
|
+
notStatedAdded: 'user_request does not say where',
|
|
754
|
+
notStatedMoved: 'user_request does not ask to move it after all',
|
|
755
|
+
top: 'At the very top of the page',
|
|
756
|
+
bottom: 'At the very bottom of the page',
|
|
757
|
+
above: (d: string) => `Just above: ${d}`,
|
|
758
|
+
below: (d: string) => `Just below: ${d}`,
|
|
759
|
+
beside: (d: string) => `Next to, side by side with: ${d}`,
|
|
760
|
+
into: (label: string) => `Inside: ${label}`,
|
|
761
|
+
}
|
|
762
|
+
/** move slots and record add slots per edit (params-edits: moveSlots 2, recordSlots 2) */
|
|
763
|
+
const MOVE_SLOTS = 2
|
|
764
|
+
const ADD_SLOTS = 2
|
|
765
|
+
|
|
766
|
+
/** a leaf (a part node) of the current layout, as the edit questions describe it */
|
|
767
|
+
interface Leaf {key: string; part: string; params?: any; place: string; at: string; row: string | null; d: string; r?: string}
|
|
768
|
+
|
|
769
|
+
/** the parts of a layout in reading order, with their places (`place`: container part + slot, for `in`; `at`: container key + slot) */
|
|
770
|
+
export function editLeaves(c: any, layout: any, state: any): Leaf[] {
|
|
771
|
+
const out: Leaf[] = []
|
|
772
|
+
const nodes = layout?.nodes || {}
|
|
773
|
+
const seen = new Set<string>()
|
|
774
|
+
const visit = (key: string, place: string, at: string, row: string | null) => {
|
|
775
|
+
const n = nodes[key]
|
|
776
|
+
if (!n || seen.has(key)) return
|
|
777
|
+
seen.add(key)
|
|
778
|
+
if (n.slots) {
|
|
779
|
+
if (n.part === ROW) { for (const k of n.slots[ROOT_SLOT] || []) visit(k, place, at, key); return }
|
|
780
|
+
for (const [s, list] of Object.entries<any>(n.slots)) for (const k of list || []) visit(k, `${n.part}:${s}`, `${key}:${s}`, null)
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
const part = partOf(c, n.part)
|
|
784
|
+
if (!part) return
|
|
785
|
+
const rp = recordParam(part)
|
|
786
|
+
let r: string | undefined
|
|
787
|
+
if (rp && n.params?.[rp] != null) {
|
|
788
|
+
const rec = recordsOf(part, rp, state).find(x => String(x.value) === String(n.params[rp]))
|
|
789
|
+
r = rec ? recordText(rec) : String(n.params[rp])
|
|
790
|
+
}
|
|
791
|
+
const leaf: Leaf = {key, part: n.part, ...(n.params && {params: n.params}), place, at, row, d: '', ...(r !== undefined && {r})}
|
|
792
|
+
leaf.d = describeLeaf(c, leaf)
|
|
793
|
+
out.push(leaf)
|
|
794
|
+
}
|
|
795
|
+
const root = layout?.root ?? ROOT
|
|
796
|
+
seen.add(root)
|
|
797
|
+
for (const k of nodes[root]?.slots?.[ROOT_SLOT] || []) visit(k, `${ROOT}:${ROOT_SLOT}`, `${root}:${ROOT_SLOT}`, null)
|
|
798
|
+
return out
|
|
799
|
+
}
|
|
800
|
+
/** describeNode: the description, its record, the params in force, and where it is when not on the page itself */
|
|
801
|
+
function describeLeaf(c: any, l: {part: string; params?: any; r?: string; place?: string}) {
|
|
802
|
+
const d = describe(c, l.part, l.params, l.r !== undefined ? {description: l.r} : undefined)
|
|
803
|
+
return l.place && l.place !== `${ROOT}:${ROOT_SLOT}` ? `${d}; inside: ${destLabel(c, l.place)}` : d
|
|
804
|
+
}
|
|
805
|
+
/** question names per node key (keys may hold ':' and '~') */
|
|
806
|
+
function qnames(keys: string[]) {
|
|
807
|
+
const used = new Set<string>(), out: any = {}
|
|
808
|
+
for (const k of keys) {
|
|
809
|
+
let q = keyPart(k)
|
|
810
|
+
for (let i = 2; used.has(q); i++) q = `${keyPart(k)}_${i}`
|
|
811
|
+
used.add(q)
|
|
812
|
+
out[k] = q
|
|
813
|
+
}
|
|
814
|
+
return out
|
|
815
|
+
}
|
|
816
|
+
const identityKey = (c: any, part: string, params: any) => {
|
|
817
|
+
const rp = recordParam(partOf(c, part))
|
|
818
|
+
return rp && params?.[rp] != null ? `${part}:${params[rp]}` : part
|
|
819
|
+
}
|
|
820
|
+
function uniqueKey(base: string, used: Set<string>) {
|
|
821
|
+
let k = base
|
|
822
|
+
for (let i = 2; used.has(k); i++) k = `${base}~${i}`
|
|
823
|
+
used.add(k)
|
|
824
|
+
return k
|
|
825
|
+
}
|
|
826
|
+
const rowable = (c: any, place: string) => hasRows(c) && (place.split(':')[0] === ROOT || !containerOf(c, place.split(':')[0])?.arranged)
|
|
827
|
+
|
|
828
|
+
/** the parts an edit may add: selectable ones not at maxUses; record parts with records the prompt names that aren't on the page */
|
|
829
|
+
function addableParts(c: any, leaves: Leaf[], records: any) {
|
|
830
|
+
return selectable(c, null).filter(id => {
|
|
831
|
+
const part = partOf(c, id)
|
|
832
|
+
if (recordParam(part)) return !!records[id]?.length
|
|
833
|
+
return leaves.filter(l => l.part === id).length < maxUses(part)
|
|
834
|
+
})
|
|
835
|
+
}
|
|
836
|
+
/** per record part, the records the prompt names (the prefilter) that are not on the page */
|
|
837
|
+
function addRecords(c: any, state: any, prompt: string, leaves: Leaf[], max: number) {
|
|
838
|
+
const out: any = {}
|
|
839
|
+
for (const [id, list] of Object.entries<any>(prefilterRecords(c, state, prompt, null, max))) {
|
|
840
|
+
const rp = recordParam(partOf(c, id)) as string
|
|
841
|
+
const on = new Set(leaves.filter(l => l.part === id).map(l => String(l.params?.[rp])))
|
|
842
|
+
const left = list.filter((r: any) => !on.has(String(r.value)))
|
|
843
|
+
if (left.length) out[id] = left
|
|
844
|
+
}
|
|
845
|
+
return out
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** edit call 1: per node remove / simpler / setting changes, adds (+ their params and record slots), the gates, move slots */
|
|
849
|
+
export function editStep(c: any, pending: any, max = 255) {
|
|
850
|
+
const leaves: Leaf[] = pending.leaves, records = pending.records || {}
|
|
851
|
+
const q = qnames(leaves.map(l => l.key))
|
|
852
|
+
const questions: any = {}, meta: any = {}
|
|
853
|
+
for (const l of leaves) {
|
|
854
|
+
const k = q[l.key]
|
|
855
|
+
questions[`remove_${k}`] = noul(EDIT.remove(l.d)); meta[`remove_${k}`] = {kind: 'remove', key: l.key}
|
|
856
|
+
questions[`simpler_${k}`] = noul(EDIT.simpler(l.d)); meta[`simpler_${k}`] = {kind: 'simpler', key: l.key}
|
|
857
|
+
for (const [name, p] of Object.entries<any>(paramsOf(partOf(c, l.part)))) {
|
|
858
|
+
if (!askable(p)) continue
|
|
859
|
+
const label = labelOf(name, p), opts = optionsOf(p), cur = String(l.params?.[name] ?? p.default)
|
|
860
|
+
chunked(questions, meta, `set_${k}${sep}${name}`, EDIT.setParam(l.d, label), {keep: EDIT.keepParam(label, opts[cur] ?? cur)},
|
|
861
|
+
Object.fromEntries(Object.entries<any>(opts).filter(([v]) => String(v) !== cur).map(([v, d]) => [String(v), d])), {kind: 'set', key: l.key, param: name}, max)
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
const add = addableParts(c, leaves, records)
|
|
865
|
+
const groups = new Set<string>()
|
|
866
|
+
for (const id of add) {
|
|
867
|
+
const part = partOf(c, id), rp = recordParam(part)
|
|
868
|
+
if (part.group != null) {
|
|
869
|
+
if (groups.has(part.group)) continue
|
|
870
|
+
groups.add(part.group)
|
|
871
|
+
const members = add.filter(m => partOf(c, m).group === part.group)
|
|
872
|
+
chunked(questions, meta, `add_g_${part.group}`, EDIT.add, {omit: TEXT.omit}, Object.fromEntries(members.map(m => {
|
|
873
|
+
const mp = partOf(c, m), mr = recordParam(mp)
|
|
874
|
+
return [m, mr ? W.recordFamily(mp.description, labelOf(mr, mp.params[mr]), records[m].map((r: any) => r.name)) : mp.description]
|
|
875
|
+
})), {kind: 'addg', group: part.group, members}, max)
|
|
876
|
+
} else if (!rp) {
|
|
877
|
+
questions[`add_${id}`] = choice(EDIT.add, {omit: TEXT.omit, use: part.description}); meta[`add_${id}`] = {kind: 'add', part: id}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
for (const id of add) {
|
|
881
|
+
const part = partOf(c, id), rp = recordParam(part)
|
|
882
|
+
if (rp) {
|
|
883
|
+
const label = labelOf(rp, part.params[rp])
|
|
884
|
+
const options = Object.fromEntries(records[id].map((r: any) => [String(r.value), r.description]))
|
|
885
|
+
for (let n = 1; n <= ADD_SLOTS; n++) chunked(questions, meta, `rec_${id}${sep}${n}`, EDIT.addSlot(part.description, label, n), {none: W.slotNone(label, n)}, options, {kind: 'rec', part: id, n}, max)
|
|
886
|
+
}
|
|
887
|
+
for (const [name, p] of Object.entries<any>(paramsOf(part))) {
|
|
888
|
+
if (!askable(p)) continue
|
|
889
|
+
const label = labelOf(name, p)
|
|
890
|
+
chunked(questions, meta, `param_${id}${sep}${name}`, W.param(part.description, label, p.ask), {not_stated: W.notStated(label)},
|
|
891
|
+
Object.fromEntries(Object.entries(optionsOf(p)).map(([v, d]) => [String(v), d])), {kind: 'param', part: id, param: name}, max)
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
questions.swap = noul(EDIT.swap); meta.swap = {kind: 'swap'}
|
|
895
|
+
questions.simpler = noul(EDIT.simplerGate); meta.simpler = {kind: 'simplerGate'}
|
|
896
|
+
questions.placed = noul(EDIT.placeGate); meta.placed = {kind: 'placed'}
|
|
897
|
+
// the settings named in the gate: the labels of the params on the page
|
|
898
|
+
const labels = [...new Set(leaves.flatMap(l => Object.entries<any>(paramsOf(partOf(c, l.part))).filter(([, p]) => askable(p)).map(([n, p]) => labelOf(n, p))))]
|
|
899
|
+
if (labels.length) { questions.change = noul(EDIT.changeGate(labels)); meta.change = {kind: 'change'} }
|
|
900
|
+
for (let n = 1; n <= MOVE_SLOTS; n++) {
|
|
901
|
+
chunked(questions, meta, `moved_${n}`, EDIT.moveSlot(n), {none: EDIT.moveSlotNone(n)}, Object.fromEntries(leaves.map(l => [l.key, l.d])), {kind: 'moved', n}, max)
|
|
902
|
+
}
|
|
903
|
+
return {state: {user_request: pending.prompt, current_page: leaves.map(l => l.d)}, questions, meta}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* call-1 answers → the plan: { removed, moved, added: [{ key, part, params?, d }], params: { key: params }, swap }.
|
|
908
|
+
* A node whose settings change is never removed; `placed` with no move slot answered moves every
|
|
909
|
+
* node (its `where` not_stated keeps it); a vague "simpler" never empties the page.
|
|
910
|
+
*/
|
|
911
|
+
export function readEdit(c: any, pending: any, a: any) {
|
|
912
|
+
const leaves: Leaf[] = pending.leaves, records = pending.records || {}
|
|
913
|
+
const q = qnames(leaves.map(l => l.key))
|
|
914
|
+
const on = (x: any) => (x?.noul ?? 0) >= 0.5
|
|
915
|
+
const plan: any = {removed: [], moved: [], added: [], params: {}, swap: on(a.swap)}
|
|
916
|
+
const simpler = on(a.simpler), changing = on(a.change)
|
|
917
|
+
const slotMoved: string[] = []
|
|
918
|
+
for (let n = 1; n <= MOVE_SLOTS; n++) {
|
|
919
|
+
const x = a[`moved_${n}`]?.choice
|
|
920
|
+
if (!x || x === 'none') break
|
|
921
|
+
if (!slotMoved.includes(x)) slotMoved.push(x)
|
|
922
|
+
}
|
|
923
|
+
for (const l of leaves) {
|
|
924
|
+
const k = q[l.key]
|
|
925
|
+
const next = {...(l.params || {})}
|
|
926
|
+
let changed = false
|
|
927
|
+
for (const [name, p] of Object.entries<any>(paramsOf(partOf(c, l.part)))) {
|
|
928
|
+
if (!askable(p)) continue
|
|
929
|
+
const v = changing ? a[`set_${k}${sep}${name}`]?.choice : null
|
|
930
|
+
if (!v || v === 'keep' || !(v in optionsOf(p))) continue
|
|
931
|
+
changed = true
|
|
932
|
+
const value = kindOf(p) == 'number' ? Number(v) : v
|
|
933
|
+
if (String(value) === String(p.default)) delete next[name]
|
|
934
|
+
else next[name] = value
|
|
935
|
+
}
|
|
936
|
+
if (!changed && (on(a[`remove_${k}`]) || (simpler && on(a[`simpler_${k}`])))) { plan.removed.push(l.key); continue }
|
|
937
|
+
if (slotMoved.includes(l.key)) plan.moved.push(l.key)
|
|
938
|
+
if (changed) plan.params[l.key] = next
|
|
939
|
+
}
|
|
940
|
+
if (slotMoved.length) plan.named = slotMoved
|
|
941
|
+
if (!plan.moved.length && on(a.placed)) plan.moved = leaves.map(l => l.key).filter(k => !plan.removed.includes(k))
|
|
942
|
+
if (plan.removed.length === leaves.length && simpler) plan.removed = plan.removed.filter((k: string) => on(a[`remove_${q[k]}`]))
|
|
943
|
+
// adds, in catalog order (record parts: in slot order); fresh keys, never one of the previous layout's
|
|
944
|
+
const used = new Set<string>(Object.keys(pending.previous?.nodes || {}))
|
|
945
|
+
for (const id of addableParts(c, leaves, records)) {
|
|
946
|
+
const part = partOf(c, id), rp = recordParam(part)
|
|
947
|
+
const asked = part.group != null ? a[`add_g_${part.group}`]?.choice === id : rp ? true : a[`add_${id}`]?.choice === 'use'
|
|
948
|
+
if (!asked) continue
|
|
949
|
+
const own = readParams(c, id, a)
|
|
950
|
+
const push = (params: any, r?: any) => {
|
|
951
|
+
const p = Object.keys(params).length ? params : undefined
|
|
952
|
+
plan.added.push({key: uniqueKey(identityKey(c, id, p), used), part: id, ...(p && {params: p}), d: describeLeaf(c, {part: id, params: p, ...(r && {r: r.description})})})
|
|
953
|
+
}
|
|
954
|
+
if (!rp) { push(own); continue }
|
|
955
|
+
// the group choice already said "these records": slot 1 is the best record even when none wins
|
|
956
|
+
const got: any[] = []
|
|
957
|
+
for (let n = 1; n <= ADD_SLOTS; n++) {
|
|
958
|
+
const x = a[`rec_${id}${sep}${n}`]
|
|
959
|
+
if (!x) break
|
|
960
|
+
const best = Object.entries<any>(x.probabilities || {}).filter(([k]) => k !== 'none').sort((u, v) => v[1] - u[1])[0]
|
|
961
|
+
const v = n === 1 && part.group != null && x.choice === 'none' && best ? best[0] : x.choice
|
|
962
|
+
if (v === 'none' || v == null) break
|
|
963
|
+
const r = records[id].find((r: any) => String(r.value) === v)
|
|
964
|
+
if (r && !got.includes(r)) got.push(r)
|
|
965
|
+
}
|
|
966
|
+
for (const r of got) push({...own, [rp]: r.value}, r)
|
|
967
|
+
}
|
|
968
|
+
return plan
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** the nodes that stay and the ones placed by call 2 (moved, then added; a replace: the added node takes the removed one's place) */
|
|
972
|
+
function editSets(c: any, pending: any) {
|
|
973
|
+
const leaves: Leaf[] = pending.leaves, p = pending.plan
|
|
974
|
+
const rep: string | null = p.swap && p.removed.length === 1 && p.added.length === 1 ? p.removed[0] : null
|
|
975
|
+
const stay = leaves.filter(l => !p.removed.includes(l.key) && !p.moved.includes(l.key))
|
|
976
|
+
// a moved node is described with its new settings
|
|
977
|
+
const moved = leaves.filter(l => p.moved.includes(l.key)).map(l => {
|
|
978
|
+
const m: any = {...l, exists: true}
|
|
979
|
+
if (p.params[l.key]) { m.params = p.params[l.key]; m.d = describeLeaf(c, m) }
|
|
980
|
+
return m
|
|
981
|
+
})
|
|
982
|
+
const added = p.added.map((x: any) => ({...x, exists: false, ...(rep && {replaces: rep})}))
|
|
983
|
+
return {rep, stay, movers: [...moved, ...added] as any[]}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** a mover's question name */
|
|
987
|
+
const moverNames = (pending: any) => qnames([...pending.leaves.map((l: Leaf) => l.key), ...pending.plan.added.map((x: any) => x.key)])
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Edit call 2 (only when something moves or is added): one `where_<key>` choice per mover over
|
|
991
|
+
* not_stated (added: the bottom, or its first allowed place; moved: it stays; a replacement: the
|
|
992
|
+
* removed node's place), top / bottom (when it may go on the page itself), above / below / beside
|
|
993
|
+
* each node that stays or moves (where its `in` allows), and `into:<container key>:<slot>` for each
|
|
994
|
+
* other place its `in` allows (a container not on the page is created).
|
|
995
|
+
*/
|
|
996
|
+
export function whereStep(c: any, pending: any, max = 255) {
|
|
997
|
+
const {stay, movers} = editSets(c, pending)
|
|
998
|
+
const q = moverNames(pending)
|
|
999
|
+
const leafD = (k: string) => pending.leaves.find((l: Leaf) => l.key === k)?.d ?? k
|
|
1000
|
+
const nodes = pending.previous?.nodes || {}
|
|
1001
|
+
const questions: any = {}, meta: any = {}
|
|
1002
|
+
for (const m of movers) {
|
|
1003
|
+
const places = allowed(c, partOf(c, m.part))
|
|
1004
|
+
const onPage = places.includes(`${ROOT}:${ROOT_SLOT}`)
|
|
1005
|
+
const head: any = {not_stated: m.replaces ? EDIT.inPlace(leafD(m.replaces)) : m.exists ? EDIT.notStatedMoved : EDIT.notStatedAdded}
|
|
1006
|
+
if (onPage) { head.top = EDIT.top; head.bottom = EDIT.bottom }
|
|
1007
|
+
const options: any = {}
|
|
1008
|
+
for (const s of [...stay, ...movers.filter(o => o.exists && o.key !== m.key)]) {
|
|
1009
|
+
if (!places.includes(s.place)) continue
|
|
1010
|
+
options[`above:${s.key}`] = EDIT.above(s.d)
|
|
1011
|
+
options[`below:${s.key}`] = EDIT.below(s.d)
|
|
1012
|
+
if (rowable(c, s.place)) options[`beside:${s.key}`] = EDIT.beside(s.d)
|
|
1013
|
+
}
|
|
1014
|
+
for (const pl of places) {
|
|
1015
|
+
if (pl === `${ROOT}:${ROOT_SLOT}`) continue
|
|
1016
|
+
const [cid, slot] = pl.split(':')
|
|
1017
|
+
const keys = Object.keys(nodes).filter(k => nodes[k]?.part === cid && nodes[k].slots)
|
|
1018
|
+
for (const ck of keys.length ? keys : [cid]) options[`into:${ck}:${slot}`] = EDIT.into(destLabel(c, pl) + (keys.length > 1 ? ` (${ck})` : ''))
|
|
1019
|
+
}
|
|
1020
|
+
chunked(questions, meta, `where_${q[m.key]}`, EDIT.where(m.d, !m.exists), head, options, {kind: 'where', key: m.key}, max)
|
|
1021
|
+
}
|
|
1022
|
+
return {state: {user_request: pending.prompt, current_page: pending.leaves.map((l: Leaf) => l.d)}, questions, meta}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* The plan + call-2 answers → the new layout (finished). Nodes that stay keep their keys, places,
|
|
1027
|
+
* rows and order; a setting change keeps the key; an added node or a replacement gets a fresh key.
|
|
1028
|
+
* A moved node that another mover is placed relative to is only the reference point and stays (of
|
|
1029
|
+
* two naming each other, the less sure one); a moved node answered not_stated stays; a
|
|
1030
|
+
* replacement answered not_stated takes the removed node's place (and row).
|
|
1031
|
+
*/
|
|
1032
|
+
export function applyEdit(c: any, pending: any, a: any, state: any) {
|
|
1033
|
+
const {rep, movers} = editSets(c, pending)
|
|
1034
|
+
const plan = pending.plan, previous = pending.previous
|
|
1035
|
+
const q = moverNames(pending)
|
|
1036
|
+
const whereOf = (m: any) => a[`where_${q[m.key]}`]
|
|
1037
|
+
const w = (m: any): string => whereOf(m)?.choice ?? 'not_stated'
|
|
1038
|
+
const anchor = (m: any) => { const x = w(m); return /^(above|below|beside):/.test(x) ? x.slice(x.indexOf(':') + 1) : null }
|
|
1039
|
+
const conf = (m: any) => whereOf(m)?.confidence ?? 0
|
|
1040
|
+
// of two naming each other, the surer one moves (a tie: the one the move slots named first)
|
|
1041
|
+
const rank = (m: any) => { const i = (plan.named || []).indexOf(m.key); return i < 0 ? Infinity : i }
|
|
1042
|
+
const surer = (m: any, o: any) => conf(m) > conf(o) || (conf(m) === conf(o) && rank(m) < rank(o))
|
|
1043
|
+
const stays = new Set(movers.filter(m =>
|
|
1044
|
+
(m.exists && movers.some(o => o !== m && anchor(o) === m.key && !(anchor(m) === o.key && surer(m, o))))
|
|
1045
|
+
|| ((m.exists || m.replaces) && w(m) === 'not_stated')).map(m => m.key))
|
|
1046
|
+
// the working tree
|
|
1047
|
+
type T = {key: string; part: string; params?: any; slots?: Record<string, T[]>}
|
|
1048
|
+
const nodes = previous.nodes
|
|
1049
|
+
const build = (k: string): T | null => {
|
|
1050
|
+
const n = nodes[k]
|
|
1051
|
+
if (!n) return null
|
|
1052
|
+
const t: T = {key: k, part: n.part, ...(n.params && {params: n.params})}
|
|
1053
|
+
if (n.slots) t.slots = Object.fromEntries(Object.entries<any>(n.slots).map(([s, l]) => [s, (l || []).map(build).filter(Boolean)]))
|
|
1054
|
+
return t
|
|
1055
|
+
}
|
|
1056
|
+
const root = build(previous.root) as T
|
|
1057
|
+
root.slots ||= {}
|
|
1058
|
+
root.slots[ROOT_SLOT] ||= []
|
|
1059
|
+
const top = root.slots[ROOT_SLOT]
|
|
1060
|
+
const locate = (key: string): {t: T; arr: T[]; i: number; parent: T} | null => {
|
|
1061
|
+
let out: any = null
|
|
1062
|
+
const go = (t: T) => {
|
|
1063
|
+
for (const s in t.slots) t.slots[s].forEach((x, i, arr) => { if (out) return; if (x.key === key) out = {t: x, arr, i, parent: t}; else if (x.slots) go(x) })
|
|
1064
|
+
}
|
|
1065
|
+
go(root)
|
|
1066
|
+
return out
|
|
1067
|
+
}
|
|
1068
|
+
const used = new Set<string>([...Object.keys(nodes), ...plan.added.map((x: any) => x.key)])
|
|
1069
|
+
const node = (m: any): T => ({key: m.key, part: m.part, ...(m.params && Object.keys(m.params).length && {params: m.params})})
|
|
1070
|
+
for (const [k, params] of Object.entries<any>(plan.params)) {
|
|
1071
|
+
const x = locate(k)
|
|
1072
|
+
if (!x) continue
|
|
1073
|
+
if (Object.keys(params).length) x.t.params = params
|
|
1074
|
+
else delete x.t.params
|
|
1075
|
+
}
|
|
1076
|
+
const repMover = rep ? movers.find(m => m.replaces) : null
|
|
1077
|
+
for (const k of plan.removed) {
|
|
1078
|
+
const x = locate(k)
|
|
1079
|
+
if (!x) continue
|
|
1080
|
+
if (repMover && k === rep && stays.has(repMover.key)) x.arr.splice(x.i, 1, node(repMover))
|
|
1081
|
+
else x.arr.splice(x.i, 1)
|
|
1082
|
+
}
|
|
1083
|
+
const detached: any = {}
|
|
1084
|
+
for (const m of movers) {
|
|
1085
|
+
if (!m.exists || stays.has(m.key)) continue
|
|
1086
|
+
const x = locate(m.key)
|
|
1087
|
+
if (x) { x.arr.splice(x.i, 1); detached[m.key] = x.t }
|
|
1088
|
+
}
|
|
1089
|
+
for (const m of movers) {
|
|
1090
|
+
if (stays.has(m.key)) continue
|
|
1091
|
+
const n = detached[m.key] ?? node(m)
|
|
1092
|
+
const x = w(m), op = x.split(':')[0]
|
|
1093
|
+
if (op === 'top') { top.unshift(n); continue }
|
|
1094
|
+
if (op === 'above' || op === 'below' || op === 'beside') {
|
|
1095
|
+
const t = locate(x.slice(x.indexOf(':') + 1))
|
|
1096
|
+
if (!t) { top.push(n); continue }
|
|
1097
|
+
if (op === 'beside') {
|
|
1098
|
+
if (t.parent.part === ROW) t.arr.splice(t.i + 1, 0, n)
|
|
1099
|
+
else t.arr.splice(t.i, 1, {key: uniqueKey(ROW, used), part: ROW, slots: {[ROOT_SLOT]: [t.t, n]}})
|
|
1100
|
+
continue
|
|
1101
|
+
}
|
|
1102
|
+
// above / below a node in a row: above / below the row
|
|
1103
|
+
const b = t.parent.part === ROW ? locate(t.parent.key) ?? t : t
|
|
1104
|
+
b.arr.splice(b.i + (op === 'below' ? 1 : 0), 0, n)
|
|
1105
|
+
continue
|
|
1106
|
+
}
|
|
1107
|
+
if (op === 'into') {
|
|
1108
|
+
const rest = x.slice(5), j = rest.lastIndexOf(':'), ck = rest.slice(0, j), slot = rest.slice(j + 1)
|
|
1109
|
+
const t = locate(ck)?.t
|
|
1110
|
+
if (t?.slots) { (t.slots[slot] ||= []).push(n); continue }
|
|
1111
|
+
if (containerOf(c, ck)) {
|
|
1112
|
+
const ct: T = {key: uniqueKey(ck, used), part: ck, slots: Object.fromEntries(slotNames(containerOf(c, ck)).map(s => [s, [] as T[]]))}
|
|
1113
|
+
;(ct.slots![slot] ||= []).push(n)
|
|
1114
|
+
top.push(ct)
|
|
1115
|
+
continue
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
// bottom; an added node with no stated place (finish() moves a part that can't be on the page to its first place)
|
|
1119
|
+
top.push(n)
|
|
1120
|
+
}
|
|
1121
|
+
const out: any = {}
|
|
1122
|
+
const emit = (t: T) => {
|
|
1123
|
+
out[t.key] = {part: t.part, ...(t.params && {params: t.params}), ...(t.slots && {slots: Object.fromEntries(Object.entries(t.slots).map(([s, l]) => [s, l.map(x => (emit(x), x.key))]))})}
|
|
1124
|
+
}
|
|
1125
|
+
emit(root)
|
|
1126
|
+
return finish(c, {root: previous.root, nodes: out}, state)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// ---------- fake for edits: answers that make an edit read as a target ----------
|
|
1130
|
+
/** a node's identity in a layout: part + record */
|
|
1131
|
+
const leafIdentity = (c: any, l: {part: string; params?: any}) => identityKey(c, l.part, l.params)
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* The edit's call-1 picks for one candidate plan; null when the target can't be expressed (a
|
|
1135
|
+
* setting a decision model can't change, a part that can't be added, too many records).
|
|
1136
|
+
*/
|
|
1137
|
+
function editPicks(c: any, pending: any, want: Leaf[], {moved, swap}: {moved: string[]; swap: boolean}) {
|
|
1138
|
+
const leaves: Leaf[] = pending.leaves, records = pending.records || {}
|
|
1139
|
+
const q = qnames(leaves.map(l => l.key))
|
|
1140
|
+
const picks: any = {swap, simpler: false}
|
|
1141
|
+
const wantBy = new Map(want.map(l => [l.key, l]))
|
|
1142
|
+
let changing = false
|
|
1143
|
+
for (const l of leaves) {
|
|
1144
|
+
const k = q[l.key], to = wantBy.get(l.key)
|
|
1145
|
+
picks[`remove_${k}`] = !to
|
|
1146
|
+
picks[`simpler_${k}`] = false
|
|
1147
|
+
for (const [name, p] of Object.entries<any>(paramsOf(partOf(c, l.part)))) {
|
|
1148
|
+
if (!to) { picks[`set_${k}${sep}${name}`] = 'keep'; continue }
|
|
1149
|
+
const cur = l.params?.[name] ?? p.default, next = to.params?.[name] ?? p.default
|
|
1150
|
+
if (String(cur) === String(next)) { if (askable(p)) picks[`set_${k}${sep}${name}`] = 'keep'; continue }
|
|
1151
|
+
if (!askable(p)) return null
|
|
1152
|
+
changing = true
|
|
1153
|
+
picks[`set_${k}${sep}${name}`] = String(next)
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
picks.change = changing
|
|
1157
|
+
// adds: one per plain part, one member per group, up to ADD_SLOTS records per record part
|
|
1158
|
+
const prevKeys = new Set(leaves.map(l => l.key))
|
|
1159
|
+
const adds = want.filter(l => !prevKeys.has(l.key))
|
|
1160
|
+
const add = addableParts(c, leaves, records)
|
|
1161
|
+
const groups: any = {}
|
|
1162
|
+
const per: any = {}
|
|
1163
|
+
for (const l of adds) {
|
|
1164
|
+
if (!add.includes(l.part)) return null
|
|
1165
|
+
const part = partOf(c, l.part)
|
|
1166
|
+
;(per[l.part] ||= []).push(l)
|
|
1167
|
+
if (part.group != null) { if (groups[part.group] && groups[part.group] !== l.part) return null; groups[part.group] = l.part }
|
|
1168
|
+
}
|
|
1169
|
+
for (const id of add) {
|
|
1170
|
+
const part = partOf(c, id), rp = recordParam(part), mine = per[id] || []
|
|
1171
|
+
if (part.group != null) picks[`add_g_${part.group}`] ??= 'omit'
|
|
1172
|
+
else if (!rp) picks[`add_${id}`] = mine.length ? 'use' : 'omit'
|
|
1173
|
+
if (!rp && mine.length > 1) return null
|
|
1174
|
+
if (rp) {
|
|
1175
|
+
if (mine.length > ADD_SLOTS) return null
|
|
1176
|
+
for (let n = 1; n <= ADD_SLOTS; n++) picks[`rec_${id}${sep}${n}`] = mine[n - 1] ? String(mine[n - 1].params?.[rp]) : 'none'
|
|
1177
|
+
}
|
|
1178
|
+
if (part.group != null && mine.length) picks[`add_g_${part.group}`] = id
|
|
1179
|
+
// params: one answer per part (its added nodes share them)
|
|
1180
|
+
for (const [name, p] of Object.entries<any>(paramsOf(part))) {
|
|
1181
|
+
if (kindOf(p) == 'record') continue
|
|
1182
|
+
const vals = [...new Set(mine.map((l: Leaf) => String(l.params?.[name] ?? p.default)))]
|
|
1183
|
+
if (vals.length > 1) return null
|
|
1184
|
+
if (!askable(p)) { if (vals.length && vals[0] !== String(p.default)) return null; continue }
|
|
1185
|
+
picks[`param_${id}${sep}${name}`] = !vals.length || vals[0] === String(p.default) ? 'not_stated' : vals[0]
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
// moves: up to MOVE_SLOTS by the move slots, more by the `placed` gate (every node gets a where)
|
|
1189
|
+
const many = moved.length > MOVE_SLOTS
|
|
1190
|
+
for (let n = 1; n <= MOVE_SLOTS; n++) picks[`moved_${n}`] = !many && moved[n - 1] ? moved[n - 1] : 'none'
|
|
1191
|
+
picks.placed = many
|
|
1192
|
+
return picks
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Where picks that read as `want`: for each mover, the options that could put it where the target
|
|
1197
|
+
* has it (beside the row-mate before it; below the node before it, above the one after it; top,
|
|
1198
|
+
* bottom, into; not_stated for the nodes meant to stay), tried depth first until the layout reads
|
|
1199
|
+
* as the target (`ok`). null when none does within the budget.
|
|
1200
|
+
*/
|
|
1201
|
+
function wherePicks(c: any, pending: any, step: any, wantLayout: any, ok: (picks: any) => boolean, stay: Set<string>, budget = {n: 400}) {
|
|
1202
|
+
const {movers} = editSets(c, pending)
|
|
1203
|
+
const q = moverNames(pending)
|
|
1204
|
+
const opts = optionsAt(step)
|
|
1205
|
+
// the target's places: per leaf, its slot sequence (rows flattened, marked) and row
|
|
1206
|
+
const W = editLeaves(c, wantLayout, {})
|
|
1207
|
+
const byKey = new Map(W.map((l, i) => [l.key, {...l, i}]))
|
|
1208
|
+
// the added movers ↔ the target's new leaves (by identity, in order)
|
|
1209
|
+
const prevKeys = new Set(pending.leaves.map((l: Leaf) => l.key))
|
|
1210
|
+
const fresh = W.filter(l => !prevKeys.has(l.key))
|
|
1211
|
+
const target = (m: any) => {
|
|
1212
|
+
if (m.exists) return byKey.get(m.key)
|
|
1213
|
+
const i = fresh.findIndex(l => leafIdentity(c, l) === leafIdentity(c, m))
|
|
1214
|
+
if (i < 0) return undefined
|
|
1215
|
+
const [l] = fresh.splice(i, 1)
|
|
1216
|
+
return byKey.get(l.key)
|
|
1217
|
+
}
|
|
1218
|
+
const at = movers.map(target)
|
|
1219
|
+
const cands = movers.map((m, i) => {
|
|
1220
|
+
const name = `where_${q[m.key]}`, keys: string[] = opts[name] || []
|
|
1221
|
+
const t = at[i]
|
|
1222
|
+
if (stay.has(m.key) && keys.includes('not_stated')) return ['not_stated']
|
|
1223
|
+
const out: string[] = []
|
|
1224
|
+
const put = (x: string) => { if (keys.includes(x) && !out.includes(x)) out.push(x) }
|
|
1225
|
+
if (m.replaces) put('not_stated')
|
|
1226
|
+
if (t) {
|
|
1227
|
+
const same = W.filter(l => l.at === t.at)
|
|
1228
|
+
const j = same.findIndex(l => l.key === t.key)
|
|
1229
|
+
if (t.row) for (let k = j - 1; k >= 0 && same[k].row === t.row; k--) put(`beside:${same[k].key}`)
|
|
1230
|
+
// a pinned part goes first in its row whichever side it joined
|
|
1231
|
+
if (t.row) for (let k = j + 1; k < same.length && same[k].row === t.row; k++) put(`beside:${same[k].key}`)
|
|
1232
|
+
const n = out.length
|
|
1233
|
+
for (let k = j - 1; k >= 0 && out.length === n; k--) if (same[k].row !== t.row || !t.row) put(`below:${same[k].key}`)
|
|
1234
|
+
const n2 = out.length
|
|
1235
|
+
for (let k = j + 1; k < same.length && out.length === n2; k++) if (same[k].row !== t.row || !t.row) put(`above:${same[k].key}`)
|
|
1236
|
+
if (t.at === `${wantLayout.root}:${ROOT_SLOT}`) { if (j === 0) put('top'); if (j === same.length - 1) put(m.exists ? 'bottom' : 'not_stated') }
|
|
1237
|
+
else for (const x of keys) if (x.startsWith('into:') && x.endsWith(`:${t.at.slice(t.at.lastIndexOf(':') + 1)}`)) put(x)
|
|
1238
|
+
// pins reorder a slot after the edit: the far anchors and the page's ends too
|
|
1239
|
+
for (let k = j - 1; k >= 0; k--) put(`below:${same[k].key}`)
|
|
1240
|
+
for (let k = j + 1; k < same.length; k++) put(`above:${same[k].key}`)
|
|
1241
|
+
put('top')
|
|
1242
|
+
put('bottom')
|
|
1243
|
+
}
|
|
1244
|
+
put('not_stated')
|
|
1245
|
+
return out.slice(0, 10)
|
|
1246
|
+
})
|
|
1247
|
+
const picks: any = {}
|
|
1248
|
+
const dfs = (i: number): boolean => {
|
|
1249
|
+
if (i === movers.length) return budget.n-- > 0 && ok(picks)
|
|
1250
|
+
for (const x of cands[i]) {
|
|
1251
|
+
if (budget.n <= 0) return false
|
|
1252
|
+
picks[`where_${q[movers[i].key]}`] = x
|
|
1253
|
+
if (dfs(i + 1)) return true
|
|
1254
|
+
}
|
|
1255
|
+
return false
|
|
1256
|
+
}
|
|
1257
|
+
return dfs(0) ? spread(step, picks) : null
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* The sets of nodes to move, smallest first (up to 4 nodes), for which the nodes that stay can stay:
|
|
1262
|
+
* each keeps its slot (or its container is gone), stays row-mates with exactly the nodes that stay it
|
|
1263
|
+
* shared a row with, and keeps its order in its slot; then every node.
|
|
1264
|
+
*/
|
|
1265
|
+
/** a layout's parts and containers (not rows) in reading order, with the container key + slot they are in */
|
|
1266
|
+
function unitsOf(layout: any) {
|
|
1267
|
+
const out: Array<{key: string; at: string}> = []
|
|
1268
|
+
const nodes = layout?.nodes || {}
|
|
1269
|
+
const visit = (k: string, at: string) => {
|
|
1270
|
+
const n = nodes[k]
|
|
1271
|
+
if (!n) return
|
|
1272
|
+
if (n.part !== ROW) out.push({key: k, at})
|
|
1273
|
+
for (const [s, list] of Object.entries<any>(n.slots || {})) for (const x of list || []) visit(x, n.part === ROW ? at : `${k}:${s}`)
|
|
1274
|
+
}
|
|
1275
|
+
for (const x of nodes[layout?.root]?.slots?.[ROOT_SLOT] || []) visit(x, `${layout.root}:${ROOT_SLOT}`)
|
|
1276
|
+
return out
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function moveSets(leaves: Leaf[], W: Leaf[], previous: any, want: any) {
|
|
1280
|
+
const wantBy = new Map(W.map(l => [l.key, l]))
|
|
1281
|
+
const surv = leaves.filter(l => wantBy.get(l.key)?.part === l.part)
|
|
1282
|
+
const prevIdx = new Map(leaves.map((l, i) => [l.key, i]))
|
|
1283
|
+
const prevUnit = new Map(unitsOf(previous).map((u, i) => [u.key, i]))
|
|
1284
|
+
const wantUnits = unitsOf(want)
|
|
1285
|
+
const box = (at: string) => at.slice(0, at.lastIndexOf(':'))
|
|
1286
|
+
const boxes = new Set(W.map(t => box(t.at)))
|
|
1287
|
+
const valid = (moved: Set<string>) => {
|
|
1288
|
+
const stay = surv.filter(l => !moved.has(l.key))
|
|
1289
|
+
for (const l of stay) { const t = wantBy.get(l.key)!; if (t.at !== l.at && boxes.has(box(l.at))) return false }
|
|
1290
|
+
for (let i = 0; i < stay.length; i++) for (let j = i + 1; j < stay.length; j++) {
|
|
1291
|
+
const a = stay[i], b = stay[j], ta = wantBy.get(a.key)!, tb = wantBy.get(b.key)!
|
|
1292
|
+
if ((!!a.row && a.row === b.row) !== (!!ta.row && ta.row === tb.row)) return false
|
|
1293
|
+
}
|
|
1294
|
+
// a row of the target needs a node that stays in it (`beside` anchors on a node that doesn't move)
|
|
1295
|
+
const rows = new Set(W.filter(t => t.row).map(t => t.row))
|
|
1296
|
+
for (const r of rows) if (!W.some(t => t.row === r && prevIdx.has(t.key) && !moved.has(t.key))) return false
|
|
1297
|
+
// in every slot, the nodes that stay and the containers that were there keep their order
|
|
1298
|
+
const last = new Map<string, number>()
|
|
1299
|
+
for (const u of wantUnits) {
|
|
1300
|
+
if (!prevUnit.has(u.key) || moved.has(u.key) || (wantBy.has(u.key) && !prevIdx.has(u.key))) continue
|
|
1301
|
+
const i = prevUnit.get(u.key)!
|
|
1302
|
+
if ((last.get(u.at) ?? -1) > i) return false
|
|
1303
|
+
last.set(u.at, i)
|
|
1304
|
+
}
|
|
1305
|
+
return true
|
|
1306
|
+
}
|
|
1307
|
+
const keys = surv.map(l => l.key)
|
|
1308
|
+
const out: string[][] = []
|
|
1309
|
+
const pick = (from: number, k: number, acc: string[]) => {
|
|
1310
|
+
if (out.length >= 24) return
|
|
1311
|
+
if (!k) { if (valid(new Set(acc))) out.push(acc); return }
|
|
1312
|
+
for (let i = from; i <= keys.length - k; i++) pick(i + 1, k - 1, [...acc, keys[i]])
|
|
1313
|
+
}
|
|
1314
|
+
for (let k = 0; k <= Math.min(4, keys.length); k++) pick(0, k, [])
|
|
1315
|
+
if (!out.some(m => m.length === keys.length)) out.push(keys)
|
|
1316
|
+
return out
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** the reply for the pending edit step (`edit` or `where`) that reads as `wantLayout`; null when no answers reach it */
|
|
1320
|
+
function fakeEditReply(c: any, state: any, pending: any, wantLayout: any, stepOf: (p: any) => any, read: (p: any, reply: any) => any) {
|
|
1321
|
+
const W = editLeaves(c, wantLayout, state)
|
|
1322
|
+
const same = (l: any) => !!l && show(canon(toTree(l))) === show(canon(toTree(wantLayout)))
|
|
1323
|
+
const budget = {n: 1500}
|
|
1324
|
+
const tryWhere = (p2: any, moved: string[]) => {
|
|
1325
|
+
const step2 = stepOf(p2)
|
|
1326
|
+
const ok = (picks: any) => { const r = read(p2, answersFor(step2.questions, picks)); return !r.error && same(r.layout) }
|
|
1327
|
+
// the movers that are meant to stay answer not_stated (the `placed` gate made every node a mover)
|
|
1328
|
+
const stay = new Set<string>(p2.plan.moved.filter((k: string) => !moved.includes(k)))
|
|
1329
|
+
return wherePicks(c, p2, step2, wantLayout, (picks: any) => ok(spread(step2, picks)), stay, budget)
|
|
1330
|
+
}
|
|
1331
|
+
const sets = moveSets(pending.leaves, W, pending.previous, wantLayout)
|
|
1332
|
+
if (pending.step === 'where') {
|
|
1333
|
+
const step2 = stepOf(pending)
|
|
1334
|
+
for (const moved of sets) {
|
|
1335
|
+
if (!moved.every(k => pending.plan.moved.includes(k))) continue
|
|
1336
|
+
const picks = tryWhere(pending, moved)
|
|
1337
|
+
if (picks) return answersFor(step2.questions, picks)
|
|
1338
|
+
}
|
|
1339
|
+
return null
|
|
1340
|
+
}
|
|
1341
|
+
const step1 = stepOf(pending)
|
|
1342
|
+
// nothing fits (an empty target) or no change: the empty plan (status 'unavailable', the layout kept)
|
|
1343
|
+
if (!W.length || same(pending.previous)) return answersFor(step1.questions, spread(step1, editPicks(c, pending, pending.leaves, {moved: [], swap: false})))
|
|
1344
|
+
const prevKeys = new Set(pending.leaves.map((l: Leaf) => l.key))
|
|
1345
|
+
const swaps = pending.leaves.filter((l: Leaf) => !W.some(t => t.key === l.key)).length === 1 && W.filter(t => !prevKeys.has(t.key)).length === 1 ? [true, false] : [false]
|
|
1346
|
+
for (const moved of sets) {
|
|
1347
|
+
for (const swap of swaps) {
|
|
1348
|
+
if (budget.n <= 0) return null
|
|
1349
|
+
const picks = editPicks(c, pending, W, {moved, swap})
|
|
1350
|
+
if (!picks) return null
|
|
1351
|
+
const reply = answersFor(step1.questions, spread(step1, picks))
|
|
1352
|
+
const out = read(pending, reply)
|
|
1353
|
+
if (out.error) continue
|
|
1354
|
+
if (!out.request) { if (same(out.layout)) return reply; continue }
|
|
1355
|
+
if (tryWhere(JSON.parse(JSON.stringify(out.pending)), moved)) return reply
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
return null
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// ---------- the backend ----------
|
|
1362
|
+
export interface DecisionsOptions {
|
|
1363
|
+
url?: string
|
|
1364
|
+
model?: string
|
|
1365
|
+
key?: string
|
|
1366
|
+
rowMean?: number
|
|
1367
|
+
prefilter?: boolean | 'auto'
|
|
1368
|
+
maxOptions?: number
|
|
1369
|
+
profile?: 'small'
|
|
1370
|
+
maxParts?: number
|
|
1371
|
+
/** passed to every decide() request (D472: a large step goes as parallel sub-requests, one merged reply) */
|
|
1372
|
+
maxQuestions?: number
|
|
1373
|
+
maxPromptTokens?: number
|
|
1374
|
+
split?: boolean
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* B-5: `prefilter: 'auto'` (the default) asks the category step first when the catalog has
|
|
1379
|
+
* categories and the select step would ask about more parts than this (Jev: the ops console's 50
|
|
1380
|
+
* parts compose as well with it, at about half the tokens and one more call, ~+0.2 s)
|
|
1381
|
+
*/
|
|
1382
|
+
export const PREFILTER_AT = 60
|
|
1383
|
+
/** the same for profile 'small' (a local model: input tokens are most of its time) */
|
|
1384
|
+
export const PREFILTER_AT_SMALL = 30
|
|
1385
|
+
/** profile 'small': the most parts one composition selects (Jev and others: the catalog's limits.maxParts, 12) */
|
|
1386
|
+
const SMALL_MAX_PARTS = 8
|
|
1387
|
+
|
|
1388
|
+
const isOllama = (url: string) => /:11434\b|ollama/i.test(url)
|
|
1389
|
+
|
|
1390
|
+
export function decisions({url = '/api/decide', model, key, rowMean = 0.25, prefilter = 'auto', maxOptions, profile, maxParts, maxQuestions, maxPromptTokens, split}: DecisionsOptions = {}) {
|
|
1391
|
+
// profile 'small' (a local 9B decision model such as Ollama's nimble, G-755): pairwise order
|
|
1392
|
+
// questions asked in both option orders (it prefers the first option), and near-ties ordered by
|
|
1393
|
+
// where the request names the elements
|
|
1394
|
+
const small = profile === 'small'
|
|
1395
|
+
const debias = small
|
|
1396
|
+
// B-5: profile 'small' selects at most 8 parts (fewer, surer picks); an explicit maxParts wins
|
|
1397
|
+
const partsCap = maxParts ?? (small ? SMALL_MAX_PARTS : undefined)
|
|
1398
|
+
/** whether a fresh composition asks the category step first */
|
|
1399
|
+
const usePrefilter = (catalog: any) => !!categoriesOf(catalog).length && (prefilter === true ||
|
|
1400
|
+
(prefilter === 'auto' && selectable(catalog).length > (small ? PREFILTER_AT_SMALL : PREFILTER_AT)))
|
|
1401
|
+
// a decide() server picks no default model: every request needs one (SYG681, the contract's
|
|
1402
|
+
// backend option problem; compose.llm() doesn't report it: a chat transport has its own default)
|
|
1403
|
+
if (typeof model != 'string' || !model) dev('SYG681', "compose.decisions() needs a `model` (the decision model's id, e.g. compose.decisions({ model: 'jev-latest' })); its requests go without one", 'error')
|
|
1404
|
+
// Ollama's /v1/systemone takes 2–26 candidates per choice, TypeSafe 255; at least 4 (a where's head is 3)
|
|
1405
|
+
const max = Math.max(4, maxOptions ?? (isOllama(url) ? 25 : 255))
|
|
1406
|
+
const request = (names: any, step: any) => decide({
|
|
1407
|
+
url, model, state: step.state, questions: step.questions,
|
|
1408
|
+
key: key ?? names?.key, latest: true, ok: names?.ok, error: names?.error,
|
|
1409
|
+
...(maxQuestions !== undefined && {maxQuestions}), ...(maxPromptTokens !== undefined && {maxPromptTokens}), ...(split !== undefined && {split}),
|
|
1410
|
+
} as any)
|
|
1411
|
+
const answersOf = (reply: any) => (reply && typeof reply == 'object' ? reply.answers : undefined)
|
|
1412
|
+
|
|
1413
|
+
/** the questions of a pending step (rebuilt from it, as sent) */
|
|
1414
|
+
function stepOf(catalog: any, pending: any) {
|
|
1415
|
+
if (pending.step === 'categories') return categoryStep(catalog, pending.prompt)
|
|
1416
|
+
if (pending.step === 'select') return selectStep(catalog, pending.prompt, {categories: pending.categories, records: pending.records, max})
|
|
1417
|
+
if (pending.step === 'edit') return editStep(catalog, pending, max)
|
|
1418
|
+
if (pending.step === 'where') return whereStep(catalog, pending, max)
|
|
1419
|
+
return layoutStep(catalog, pending.prompt, {items: pending.items, row: pending.row}, max, debias)
|
|
1420
|
+
}
|
|
1421
|
+
function selectPending(catalog: any, state: any, prompt: string, categories: string[] | null, base: any) {
|
|
1422
|
+
const records = prefilterRecords(catalog, state, prompt, categories, max)
|
|
1423
|
+
return {...base, step: 'select', prompt, categories, records}
|
|
1424
|
+
}
|
|
1425
|
+
const done = (catalog: any, state: any, tree: any[], previous: any) => finish(catalog, fromTree(catalog, tree, previous), state)
|
|
1426
|
+
|
|
1427
|
+
/** a reply → the backend's result (pure; fake() reads with it too) */
|
|
1428
|
+
function read({catalog, state, pending, reply, names}: any) {
|
|
1429
|
+
const step = stepOf(catalog, pending)
|
|
1430
|
+
const body = answersOf(reply)
|
|
1431
|
+
// a reply that can't be read: the behavior's status 'error' (contract), not a throw
|
|
1432
|
+
if (!body || typeof body != 'object' || Array.isArray(body)) return {error: 'compose.decisions(): the decision reply has no answers'}
|
|
1433
|
+
const {answers: raw, bad} = validate(step.questions, body)
|
|
1434
|
+
if (bad && !Object.keys(raw).length) return {error: `compose.decisions(): every answer of the ${pending.step} step is outside its question's options (SYG682)`}
|
|
1435
|
+
const decisions = {...(pending.decisions || {}), [pending.step]: raw}
|
|
1436
|
+
const a = unchunk(raw, step.meta)
|
|
1437
|
+
const base = {previous: pending.previous ?? null, decisions}
|
|
1438
|
+
// an edit (B-4): keys come from the previous layout (`keyed`: the behavior doesn't re-key it)
|
|
1439
|
+
if (pending.step === 'edit') {
|
|
1440
|
+
const next = {...base, step: 'where', prompt: pending.prompt, leaves: pending.leaves, records: pending.records, plan: readEdit(catalog, pending, a)}
|
|
1441
|
+
const pl = next.plan
|
|
1442
|
+
// nothing to remove, change, move or add: nothing in the request applies (the layout is kept)
|
|
1443
|
+
if (!pl.removed.length && !pl.moved.length && !pl.added.length && !Object.keys(pl.params).length) return {layout: null, unavailable: true as const, decisions}
|
|
1444
|
+
const now = applyEdit(catalog, next, {}, state)
|
|
1445
|
+
if (!editSets(catalog, next).movers.length) return {layout: now, keyed: true, decisions}
|
|
1446
|
+
return {preview: now, request: request(names, stepOf(catalog, next)), pending: next, keyed: true, decisions}
|
|
1447
|
+
}
|
|
1448
|
+
if (pending.step === 'where') return {layout: applyEdit(catalog, pending, a, state), keyed: true, decisions}
|
|
1449
|
+
if (pending.step === 'categories') {
|
|
1450
|
+
const chosen = categoriesOf(catalog).filter(id => a[`cat_${id}`]?.choice === 'use')
|
|
1451
|
+
const open = partIds(catalog).some(id => !partOf(catalog, id).required && partOf(catalog, id).category == null)
|
|
1452
|
+
if (!chosen.length && !open) return {layout: null, unavailable: true as const, decisions}
|
|
1453
|
+
const next = selectPending(catalog, state, pending.prompt, chosen, base)
|
|
1454
|
+
return {request: request(names, stepOf(catalog, next)), pending: next, decisions}
|
|
1455
|
+
}
|
|
1456
|
+
if (pending.step === 'select') {
|
|
1457
|
+
const sel = readSelect(catalog, a, {categories: pending.categories, records: pending.records, maxParts: partsCap})
|
|
1458
|
+
if (sel.unavailable) return {layout: null, unavailable: true as const, decisions}
|
|
1459
|
+
const p = plan(catalog, sel)
|
|
1460
|
+
if (!needsLayout(p)) return {layout: done(catalog, state, readLayout(catalog, sel, {}, {rowMean, prompt: small ? pending.prompt : null}), pending.previous), decisions}
|
|
1461
|
+
const next = {...base, step: 'layout', prompt: pending.prompt, items: sel.items, row: sel.row}
|
|
1462
|
+
const preview = done(catalog, state, sel.items.map((i: any) => ({part: i.part, ...(i.params && {params: i.params})})), pending.previous)
|
|
1463
|
+
return {preview, request: request(names, stepOf(catalog, next)), pending: next, decisions}
|
|
1464
|
+
}
|
|
1465
|
+
const tree = readLayout(catalog, {items: pending.items, row: pending.row}, a, {rowMean, prompt: small ? pending.prompt : null})
|
|
1466
|
+
return {layout: done(catalog, state, tree, pending.previous), decisions}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
return {
|
|
1470
|
+
sink: 'HTTP',
|
|
1471
|
+
kind: 'decisions',
|
|
1472
|
+
start({catalog, state, prompt, previous, names}: any) {
|
|
1473
|
+
const base = previous ? {previous} : {}
|
|
1474
|
+
// a layout with parts on it: an edit (2 calls at most); `fresh: true` passes previous = null
|
|
1475
|
+
const leaves = previous ? editLeaves(catalog, previous, state) : []
|
|
1476
|
+
if (leaves.length) {
|
|
1477
|
+
const pending = {...base, step: 'edit', prompt, leaves, records: addRecords(catalog, state, prompt, leaves, max)}
|
|
1478
|
+
return {request: request(names, stepOf(catalog, pending)), pending}
|
|
1479
|
+
}
|
|
1480
|
+
const pending = usePrefilter(catalog)
|
|
1481
|
+
? {...base, step: 'categories', prompt}
|
|
1482
|
+
: selectPending(catalog, state, prompt, null, base)
|
|
1483
|
+
return {request: request(names, stepOf(catalog, pending)), pending}
|
|
1484
|
+
},
|
|
1485
|
+
reply: read,
|
|
1486
|
+
/** the reply body that makes the pending step read as `target` (for composeWith); throws when it can't */
|
|
1487
|
+
fake({catalog, state, pending, target}: any) {
|
|
1488
|
+
const step = stepOf(catalog, pending)
|
|
1489
|
+
if (pending.step === 'edit' || pending.step === 'where') {
|
|
1490
|
+
const want = finish(catalog, fromTree(catalog, target || [], pending.previous), state)
|
|
1491
|
+
const reply = fakeEditReply(catalog, state, pending, want, (p: any) => stepOf(catalog, p), (p: any, reply: any) => read({catalog, state, pending: p, reply, names: {}}))
|
|
1492
|
+
if (!reply) throw new Error(`[Sygnal] compose.decisions() fake: can't reach the target ${show(toTree(want))} as an edit of ${show(toTree(pending.previous))} (an edit removes, changes, moves up to any number of parts and adds parts beside, above, below or into others; compose it with { fresh: true } instead)`)
|
|
1493
|
+
return reply
|
|
1494
|
+
}
|
|
1495
|
+
const want = toTree(finish(catalog, fromTree(catalog, target || [], pending.previous ?? undefined), state))
|
|
1496
|
+
const picks = fakePicks(catalog, pending, step, want)
|
|
1497
|
+
const reply = answersFor(step.questions, picks)
|
|
1498
|
+
check(catalog, state, pending, want, read({catalog, state, pending, reply, names: {}}))
|
|
1499
|
+
return reply
|
|
1500
|
+
},
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// ---------- fake: answers that read as a target tree (structure/evals/selftest.mjs) ----------
|
|
1505
|
+
/** the target's leaves (DFS order) and containers, with their places */
|
|
1506
|
+
function walkTarget(c: any, tree: any[]) {
|
|
1507
|
+
const leaves: any[] = [], containers: any = {}, rows: any[] = []
|
|
1508
|
+
const visit = (list: any[], where: string, row: any) => {
|
|
1509
|
+
for (const n of list || []) {
|
|
1510
|
+
if (n.part === ROW) {
|
|
1511
|
+
const r: any[] = []
|
|
1512
|
+
rows.push(r)
|
|
1513
|
+
visit(Array.isArray(n.children) ? n.children : n.children?.[ROOT_SLOT] || [], where, r)
|
|
1514
|
+
} else if (containerOf(c, n.part)) {
|
|
1515
|
+
containers[n.part] = where
|
|
1516
|
+
if (Array.isArray(n.children)) visit(n.children, `${n.part}:${ROOT_SLOT}`, null)
|
|
1517
|
+
else for (const [s, l] of Object.entries<any>(n.children || {})) visit(l, `${n.part}:${s}`, null)
|
|
1518
|
+
} else {
|
|
1519
|
+
const leaf = {part: n.part, params: n.params || {}, where, row, i: leaves.length}
|
|
1520
|
+
leaves.push(leaf)
|
|
1521
|
+
row?.push(leaf)
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
visit(tree, `${ROOT}:${ROOT_SLOT}`, null)
|
|
1526
|
+
return {leaves, containers, rows}
|
|
1527
|
+
}
|
|
1528
|
+
const one = (choice: string) => ({choice, probabilities: {[choice]: 1}})
|
|
1529
|
+
|
|
1530
|
+
/** the options of a (possibly chunked) question, by its base name */
|
|
1531
|
+
function optionsAt(step: any) {
|
|
1532
|
+
const out: any = {}
|
|
1533
|
+
for (const [name, m] of Object.entries<any>(step.meta)) {
|
|
1534
|
+
const q = step.questions[name]
|
|
1535
|
+
if (q?.type !== 'choice') continue
|
|
1536
|
+
const base = m.base ?? name
|
|
1537
|
+
const have: string[] = out[base] || []
|
|
1538
|
+
out[base] = [...have, ...Object.keys(q.criteria).filter(k => !have.includes(k))]
|
|
1539
|
+
}
|
|
1540
|
+
return out
|
|
1541
|
+
}
|
|
1542
|
+
/** fake answers by base name → picks per question (a chunk without the wanted option answers its head, else a 1% option) */
|
|
1543
|
+
function spread(step: any, wanted: any) {
|
|
1544
|
+
const picks: any = {}
|
|
1545
|
+
for (const [name, m] of Object.entries<any>(step.meta)) {
|
|
1546
|
+
const q = step.questions[name], w = wanted[m.base ?? name]
|
|
1547
|
+
if (w === undefined || !q) continue
|
|
1548
|
+
if (q.type !== 'choice') { picks[name] = w; continue }
|
|
1549
|
+
const keys = Object.keys(q.criteria)
|
|
1550
|
+
if (keys.includes(String(w))) picks[name] = one(String(w))
|
|
1551
|
+
else if (m.head?.length) picks[name] = one(m.head[0])
|
|
1552
|
+
else picks[name] = {choice: keys[0], probabilities: {[keys[0]]: 0.01}}
|
|
1553
|
+
}
|
|
1554
|
+
return picks
|
|
1555
|
+
}
|
|
1556
|
+
/** each question's meta once, by base name */
|
|
1557
|
+
const metasOf = (step: any) => {
|
|
1558
|
+
const seen = new Set<string>()
|
|
1559
|
+
return Object.entries<any>(step.meta).filter(([name, m]) => { const b = m.base ?? name; if (seen.has(b)) return false; seen.add(b); return true }).map(([name, m]) => [m.base ?? name, m] as [string, any])
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function fakePicks(c: any, pending: any, step: any, want: any[]) {
|
|
1563
|
+
const {leaves, containers} = walkTarget(c, want)
|
|
1564
|
+
const picks: any = {}
|
|
1565
|
+
const opts = optionsAt(step)
|
|
1566
|
+
const has = (id: string) => leaves.filter(l => l.part === id)
|
|
1567
|
+
if (pending.step === 'categories' || pending.step === 'select') {
|
|
1568
|
+
for (const [name, m] of metasOf(step)) {
|
|
1569
|
+
const keys: string[] = opts[name] || []
|
|
1570
|
+
if (m.kind === 'cat') picks[name] = leaves.some(l => partOf(c, l.part)?.category === m.category) ? 'use' : 'omit'
|
|
1571
|
+
else if (m.kind === 'use') picks[name] = has(m.part).length ? 'use' : 'omit'
|
|
1572
|
+
else if (m.kind === 'count') picks[name] = String(Math.min(has(m.part).length, maxUses(partOf(c, m.part))))
|
|
1573
|
+
else if (m.kind === 'pick') picks[name] = m.members.find((id: string) => has(id).length) ?? 'omit'
|
|
1574
|
+
else if (m.kind === 'rec') {
|
|
1575
|
+
const rp = recordParam(partOf(c, m.part))
|
|
1576
|
+
const values = has(m.part).map(l => String(l.params?.[rp as string]))
|
|
1577
|
+
const v = values[m.n - 1]
|
|
1578
|
+
picks[name] = v !== undefined && keys.includes(v) ? v : 'none'
|
|
1579
|
+
} else if (m.kind === 'param') {
|
|
1580
|
+
const v = has(m.part).map(l => l.params?.[m.param]).find(x => x !== undefined)
|
|
1581
|
+
const s = v === undefined ? 'not_stated' : String(v)
|
|
1582
|
+
picks[name] = keys.includes(s) ? s : 'not_stated'
|
|
1583
|
+
} else if (m.kind === 'row') picks[name] = leaves.some(l => l.row)
|
|
1584
|
+
}
|
|
1585
|
+
return spread(step, picks)
|
|
1586
|
+
}
|
|
1587
|
+
// layout: match the selected items to the target's leaves (part + record, in order)
|
|
1588
|
+
const items = pending.items
|
|
1589
|
+
const taken = new Set<any>()
|
|
1590
|
+
const leafOf: any = {}
|
|
1591
|
+
for (const it of items) {
|
|
1592
|
+
const rp = recordParam(partOf(c, it.part))
|
|
1593
|
+
const l = leaves.find(l => !taken.has(l) && l.part === it.part && (!rp || String(l.params?.[rp]) === String(it.params?.[rp])))
|
|
1594
|
+
if (l) { taken.add(l); leafOf[it.key] = l }
|
|
1595
|
+
}
|
|
1596
|
+
const pos = (k: string) => leafOf[k]?.i ?? Infinity
|
|
1597
|
+
for (const [name, m] of metasOf(step)) {
|
|
1598
|
+
const keys: string[] = opts[name] || []
|
|
1599
|
+
if (m.kind === 'parent') {
|
|
1600
|
+
const where = leafOf[m.key]?.where ?? containers[m.key]
|
|
1601
|
+
picks[name] = keys.includes(where) ? where : keys[0]
|
|
1602
|
+
} else if (m.kind === 'beside') {
|
|
1603
|
+
const row = leafOf[m.key]?.row
|
|
1604
|
+
if (!row) { picks[name] = 'none'; continue }
|
|
1605
|
+
// a ring: each member names the next one (the last names the first), so average-linkage keeps
|
|
1606
|
+
// the row together at rowMean 0.25 for up to 6 members
|
|
1607
|
+
const mates = row.map((l: any) => Object.keys(leafOf).find(k => leafOf[k] === l))
|
|
1608
|
+
const next = mates[(mates.indexOf(m.key) + 1) % mates.length]
|
|
1609
|
+
picks[name] = keys.includes(next) ? next : 'none'
|
|
1610
|
+
} else if (m.kind === 'same') picks[name] = !!leafOf[m.a]?.row && leafOf[m.a].row === leafOf[m.b]?.row
|
|
1611
|
+
else if (m.kind === 'left' || m.kind === 'first') picks[name] = pos(m.a) <= pos(m.b) ? m.a : m.b
|
|
1612
|
+
else if (m.kind === 'gap') {
|
|
1613
|
+
const g = leaves.filter(l => l.part === m.part && l.i < pos(m.key)).length
|
|
1614
|
+
picks[name] = String(Math.min(g, keys.length - 1))
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
return spread(step, picks)
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
const canon = (x: any): any => Array.isArray(x) ? x.map(canon)
|
|
1621
|
+
: x && typeof x == 'object' ? Object.fromEntries(Object.keys(x).sort().map(k => [k, canon(x[k])])) : x
|
|
1622
|
+
const show = (t: any) => JSON.stringify(t)
|
|
1623
|
+
|
|
1624
|
+
/** throws when reading the fake's answers doesn't give the target (an unreachable target) */
|
|
1625
|
+
function check(c: any, state: any, pending: any, want: any[], out: any) {
|
|
1626
|
+
const fail = (why: string) => { throw new Error(`[Sygnal] compose.decisions() fake: can't reach the target ${show(want)}: ${why}`) }
|
|
1627
|
+
if (pending.step === 'select') {
|
|
1628
|
+
for (const l of walkTarget(c, want).leaves) {
|
|
1629
|
+
const rp = recordParam(partOf(c, l.part))
|
|
1630
|
+
if (rp && !(pending.records?.[l.part] || []).some((r: any) => String(r.value) === String(l.params?.[rp]))) {
|
|
1631
|
+
fail(`the prompt ${JSON.stringify(pending.prompt)} doesn't name the ${l.part} record ${JSON.stringify(l.params?.[rp])} (records are found by name or alias in the prompt)`)
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
if (out.request) {
|
|
1636
|
+
if (pending.step !== 'select') return
|
|
1637
|
+
const got = out.pending.items.map((i: any) => show(canon({part: i.part, params: i.params || {}}))).sort()
|
|
1638
|
+
const exp = walkTarget(c, want).leaves.map(l => show(canon({part: l.part, params: l.params || {}}))).sort()
|
|
1639
|
+
if (show(got) !== show(exp)) fail(`the select step reads as ${got.join(' ')}`)
|
|
1640
|
+
return
|
|
1641
|
+
}
|
|
1642
|
+
if (out.unavailable) {
|
|
1643
|
+
// nothing fits: the target holds nothing but required parts (which finish() adds anyway)
|
|
1644
|
+
if (walkTarget(c, want).leaves.some(l => !partOf(c, l.part)?.required)) fail('the answers read as "nothing fits"')
|
|
1645
|
+
return
|
|
1646
|
+
}
|
|
1647
|
+
const got = toTree(out.layout)
|
|
1648
|
+
if (show(canon(got)) !== show(canon(want))) fail(`the answers read as ${show(got)}`)
|
|
1649
|
+
}
|