@geoqiao/pi-ask 1.1.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 +194 -0
- package/LICENSE +22 -0
- package/README.md +282 -0
- package/docs/README.md +33 -0
- package/docs/configuration.md +406 -0
- package/docs/contract.md +309 -0
- package/docs/remote-events.md +187 -0
- package/package.json +130 -0
- package/skills/ask-user/SKILL.md +110 -0
- package/src/answer-commands.ts +361 -0
- package/src/answer-extraction.ts +354 -0
- package/src/ask-payload-store.ts +86 -0
- package/src/ask-settings-command.ts +14 -0
- package/src/ask-tool-helpers.ts +172 -0
- package/src/ask-tool.ts +84 -0
- package/src/config/defaults.ts +216 -0
- package/src/config/migrate.ts +70 -0
- package/src/config/migrations/index.ts +139 -0
- package/src/config/migrations/types.ts +10 -0
- package/src/config/schema.ts +287 -0
- package/src/config/store.ts +227 -0
- package/src/constants/keymaps.ts +721 -0
- package/src/constants/text.ts +12 -0
- package/src/constants/ui.ts +22 -0
- package/src/index.ts +30 -0
- package/src/math.ts +3 -0
- package/src/notifications.ts +119 -0
- package/src/remote-ask.ts +563 -0
- package/src/result-format.ts +157 -0
- package/src/result.ts +23 -0
- package/src/schema.ts +74 -0
- package/src/state/answers.ts +251 -0
- package/src/state/create.ts +18 -0
- package/src/state/editor.ts +70 -0
- package/src/state/navigation.ts +86 -0
- package/src/state/normalize.ts +326 -0
- package/src/state/question-type.ts +128 -0
- package/src/state/result.ts +263 -0
- package/src/state/selectors.ts +135 -0
- package/src/state/transitions.ts +330 -0
- package/src/state/view.ts +28 -0
- package/src/text.ts +98 -0
- package/src/types.ts +169 -0
- package/src/ui/auto-submit.ts +36 -0
- package/src/ui/autocomplete.ts +52 -0
- package/src/ui/controller.ts +645 -0
- package/src/ui/dismiss-guard.ts +26 -0
- package/src/ui/input.ts +160 -0
- package/src/ui/render-frame.ts +235 -0
- package/src/ui/render-helpers.ts +385 -0
- package/src/ui/render-question.ts +288 -0
- package/src/ui/render-submit.ts +168 -0
- package/src/ui/render-types.ts +33 -0
- package/src/ui/render.ts +53 -0
- package/src/ui/review-shortcuts.ts +43 -0
- package/src/ui/settings-list.ts +461 -0
- package/src/ui/show-settings.ts +37 -0
- package/src/ui/view-models/question.ts +203 -0
- package/src/ui/view-models/review.ts +100 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AskOption,
|
|
3
|
+
AskParams,
|
|
4
|
+
AskQuestion,
|
|
5
|
+
AskQuestionInput,
|
|
6
|
+
AskValidationIssue,
|
|
7
|
+
} from "../types.ts";
|
|
8
|
+
|
|
9
|
+
interface IssueCollector {
|
|
10
|
+
add: (path: string, message: string) => void;
|
|
11
|
+
issues: AskValidationIssue[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ValidationOptions {
|
|
15
|
+
allowFreeform?: boolean;
|
|
16
|
+
presentSingleAsMulti?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizeQuestions(
|
|
20
|
+
params: AskParams,
|
|
21
|
+
options: ValidationOptions = {}
|
|
22
|
+
): AskQuestion[] {
|
|
23
|
+
const issues = collectValidationIssues(params, options);
|
|
24
|
+
if (issues.length > 0) {
|
|
25
|
+
throw new Error(issues[0]?.message ?? "Invalid ask_user payload");
|
|
26
|
+
}
|
|
27
|
+
return params.questions.map((question, index) =>
|
|
28
|
+
normalizeQuestion(question, index, options)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function collectValidationIssues(
|
|
33
|
+
params: AskParams,
|
|
34
|
+
options: ValidationOptions = {}
|
|
35
|
+
): AskValidationIssue[] {
|
|
36
|
+
const collector = createIssueCollector();
|
|
37
|
+
validateQuestions(params.questions, collector, options);
|
|
38
|
+
return collector.issues;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeQuestion(
|
|
42
|
+
question: AskQuestionInput,
|
|
43
|
+
index: number,
|
|
44
|
+
options: ValidationOptions = {}
|
|
45
|
+
): AskQuestion {
|
|
46
|
+
const requestedType = normalizeQuestionType(question.type);
|
|
47
|
+
const presentedType =
|
|
48
|
+
options.presentSingleAsMulti && requestedType === "single"
|
|
49
|
+
? "multi"
|
|
50
|
+
: requestedType;
|
|
51
|
+
return {
|
|
52
|
+
id: question.id.trim(),
|
|
53
|
+
label: question.label?.trim() || `Q${index + 1}`,
|
|
54
|
+
prompt: question.prompt.trim(),
|
|
55
|
+
type: presentedType,
|
|
56
|
+
...(presentedType === requestedType
|
|
57
|
+
? {}
|
|
58
|
+
: { requestedType, presentedType }),
|
|
59
|
+
required: question.required ?? false,
|
|
60
|
+
options: question.options.map(normalizeOption),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeOption(option: AskOption): AskOption {
|
|
65
|
+
return {
|
|
66
|
+
value: option.value.trim(),
|
|
67
|
+
label: option.label.trim(),
|
|
68
|
+
description: option.description?.trim(),
|
|
69
|
+
preview: option.preview?.trim(),
|
|
70
|
+
...(option.freeform ? { freeform: true } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateQuestions(
|
|
75
|
+
questions: AskParams["questions"],
|
|
76
|
+
collector: IssueCollector,
|
|
77
|
+
options: ValidationOptions
|
|
78
|
+
) {
|
|
79
|
+
if (questions.length === 0) {
|
|
80
|
+
collector.add("questions", "At least one question is required");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const questionIds = new Set<string>();
|
|
85
|
+
for (const [questionIndex, question] of questions.entries()) {
|
|
86
|
+
validateQuestion(question, questionIndex, questionIds, collector, options);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateQuestion(
|
|
91
|
+
question: AskQuestionInput,
|
|
92
|
+
questionIndex: number,
|
|
93
|
+
questionIds: Set<string>,
|
|
94
|
+
collector: IssueCollector,
|
|
95
|
+
options: ValidationOptions
|
|
96
|
+
) {
|
|
97
|
+
const questionNumber = questionIndex + 1;
|
|
98
|
+
const questionPath = `questions[${questionIndex}]`;
|
|
99
|
+
const questionId = question.id?.trim();
|
|
100
|
+
const questionType = normalizeQuestionType(question.type);
|
|
101
|
+
|
|
102
|
+
validateQuestionType(
|
|
103
|
+
question.type,
|
|
104
|
+
questionNumber,
|
|
105
|
+
collector,
|
|
106
|
+
`${questionPath}.type`
|
|
107
|
+
);
|
|
108
|
+
assertRequired(
|
|
109
|
+
questionId,
|
|
110
|
+
collector,
|
|
111
|
+
`${questionPath}.id`,
|
|
112
|
+
`Question ${questionNumber}: id is required`
|
|
113
|
+
);
|
|
114
|
+
assertUnique(
|
|
115
|
+
questionIds,
|
|
116
|
+
questionId,
|
|
117
|
+
collector,
|
|
118
|
+
`${questionPath}.id`,
|
|
119
|
+
`Question ${questionNumber}: duplicate question id "${questionId}"`
|
|
120
|
+
);
|
|
121
|
+
assertOptionalText(
|
|
122
|
+
question.label,
|
|
123
|
+
collector,
|
|
124
|
+
`${questionPath}.label`,
|
|
125
|
+
`Question ${questionNumber}: label must not be empty`
|
|
126
|
+
);
|
|
127
|
+
assertRequired(
|
|
128
|
+
question.prompt?.trim(),
|
|
129
|
+
collector,
|
|
130
|
+
`${questionPath}.prompt`,
|
|
131
|
+
`Question ${questionNumber}: prompt is required`
|
|
132
|
+
);
|
|
133
|
+
assertHasItems(
|
|
134
|
+
question.options,
|
|
135
|
+
collector,
|
|
136
|
+
`${questionPath}.options`,
|
|
137
|
+
`Question ${questionNumber}: at least one option is required`
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
validateFreeformOptions(
|
|
141
|
+
question.options,
|
|
142
|
+
questionNumber,
|
|
143
|
+
collector,
|
|
144
|
+
`${questionPath}.options`,
|
|
145
|
+
options
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const optionValues = new Set<string>();
|
|
149
|
+
for (const [optionIndex, option] of question.options.entries()) {
|
|
150
|
+
validateOption(
|
|
151
|
+
option,
|
|
152
|
+
optionIndex,
|
|
153
|
+
optionValues,
|
|
154
|
+
questionNumber,
|
|
155
|
+
questionType,
|
|
156
|
+
collector,
|
|
157
|
+
`${questionPath}.options[${optionIndex}]`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validateFreeformOptions(
|
|
163
|
+
options: AskOption[],
|
|
164
|
+
questionNumber: number,
|
|
165
|
+
collector: IssueCollector,
|
|
166
|
+
path: string,
|
|
167
|
+
validationOptions: ValidationOptions
|
|
168
|
+
) {
|
|
169
|
+
const freeformCount = options.filter((option) => option.freeform).length;
|
|
170
|
+
if (freeformCount === 0) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (!validationOptions.allowFreeform) {
|
|
174
|
+
collector.add(
|
|
175
|
+
path,
|
|
176
|
+
`Question ${questionNumber}: freeform options are only supported for /answer forms`
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (freeformCount > 1 || options.length > 1) {
|
|
181
|
+
collector.add(
|
|
182
|
+
path,
|
|
183
|
+
`Question ${questionNumber}: freeform options must be the only option`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function validateOption(
|
|
189
|
+
option: AskOption,
|
|
190
|
+
optionIndex: number,
|
|
191
|
+
optionValues: Set<string>,
|
|
192
|
+
questionNumber: number,
|
|
193
|
+
questionType: AskQuestion["type"],
|
|
194
|
+
collector: IssueCollector,
|
|
195
|
+
optionPath: string
|
|
196
|
+
) {
|
|
197
|
+
const optionNumber = optionIndex + 1;
|
|
198
|
+
const prefix = `Question ${questionNumber}, option ${optionNumber}`;
|
|
199
|
+
const optionValue = option.value?.trim();
|
|
200
|
+
const optionPreview = option.preview?.trim();
|
|
201
|
+
|
|
202
|
+
assertRequired(
|
|
203
|
+
optionValue,
|
|
204
|
+
collector,
|
|
205
|
+
`${optionPath}.value`,
|
|
206
|
+
`${prefix}: value is required`
|
|
207
|
+
);
|
|
208
|
+
assertUnique(
|
|
209
|
+
optionValues,
|
|
210
|
+
optionValue,
|
|
211
|
+
collector,
|
|
212
|
+
`${optionPath}.value`,
|
|
213
|
+
`${prefix}: duplicate option value "${optionValue}"`
|
|
214
|
+
);
|
|
215
|
+
assertRequired(
|
|
216
|
+
option.label?.trim(),
|
|
217
|
+
collector,
|
|
218
|
+
`${optionPath}.label`,
|
|
219
|
+
`${prefix}: label is required`
|
|
220
|
+
);
|
|
221
|
+
assertOptionalText(
|
|
222
|
+
option.description,
|
|
223
|
+
collector,
|
|
224
|
+
`${optionPath}.description`,
|
|
225
|
+
`${prefix}: description must not be empty`
|
|
226
|
+
);
|
|
227
|
+
assertOptionalText(
|
|
228
|
+
option.preview,
|
|
229
|
+
collector,
|
|
230
|
+
`${optionPath}.preview`,
|
|
231
|
+
`${prefix}: preview must not be empty`
|
|
232
|
+
);
|
|
233
|
+
if (questionType === "preview") {
|
|
234
|
+
assertRequired(
|
|
235
|
+
optionPreview,
|
|
236
|
+
collector,
|
|
237
|
+
`${optionPath}.preview`,
|
|
238
|
+
`${prefix}: preview questions require preview text for every option; add preview text or use type "single" instead`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function normalizeQuestionType(
|
|
244
|
+
value: AskQuestionInput["type"]
|
|
245
|
+
): AskQuestion["type"] {
|
|
246
|
+
return value === "multi" || value === "preview" ? value : "single";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function validateQuestionType(
|
|
250
|
+
value: AskQuestionInput["type"],
|
|
251
|
+
questionNumber: number,
|
|
252
|
+
collector: IssueCollector,
|
|
253
|
+
path: string
|
|
254
|
+
) {
|
|
255
|
+
if (
|
|
256
|
+
value !== undefined &&
|
|
257
|
+
value !== "single" &&
|
|
258
|
+
value !== "multi" &&
|
|
259
|
+
value !== "preview"
|
|
260
|
+
) {
|
|
261
|
+
collector.add(
|
|
262
|
+
path,
|
|
263
|
+
`Question ${questionNumber}: invalid type ${JSON.stringify(value)}; expected "single", "multi", or "preview"`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function assertHasItems(
|
|
269
|
+
items: unknown[],
|
|
270
|
+
collector: IssueCollector,
|
|
271
|
+
path: string,
|
|
272
|
+
message: string
|
|
273
|
+
) {
|
|
274
|
+
if (items.length === 0) {
|
|
275
|
+
collector.add(path, message);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function assertRequired(
|
|
280
|
+
value: string | undefined,
|
|
281
|
+
collector: IssueCollector,
|
|
282
|
+
path: string,
|
|
283
|
+
message: string
|
|
284
|
+
) {
|
|
285
|
+
if (!value) {
|
|
286
|
+
collector.add(path, message);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function assertOptionalText(
|
|
291
|
+
value: string | undefined,
|
|
292
|
+
collector: IssueCollector,
|
|
293
|
+
path: string,
|
|
294
|
+
message: string
|
|
295
|
+
) {
|
|
296
|
+
if (value !== undefined && !value.trim()) {
|
|
297
|
+
collector.add(path, message);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function assertUnique(
|
|
302
|
+
seen: Set<string>,
|
|
303
|
+
value: string | undefined,
|
|
304
|
+
collector: IssueCollector,
|
|
305
|
+
path: string,
|
|
306
|
+
message: string
|
|
307
|
+
) {
|
|
308
|
+
if (!value) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (seen.has(value)) {
|
|
312
|
+
collector.add(path, message);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
seen.add(value);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function createIssueCollector(): IssueCollector {
|
|
319
|
+
const issues: AskValidationIssue[] = [];
|
|
320
|
+
return {
|
|
321
|
+
issues,
|
|
322
|
+
add(path, message) {
|
|
323
|
+
issues.push({ path, message });
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { AskQuestionType, AskState, AskStateAnswer } from "../types.ts";
|
|
2
|
+
import { isAnswerEmpty } from "./answers.ts";
|
|
3
|
+
import { getAnswer, getCurrentQuestion, isSubmitTab } from "./selectors.ts";
|
|
4
|
+
|
|
5
|
+
export interface QuestionTypeChangeResult {
|
|
6
|
+
needsConfirmation: boolean;
|
|
7
|
+
notice?: string;
|
|
8
|
+
state: AskState;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function cycleCurrentQuestionType(
|
|
12
|
+
state: AskState,
|
|
13
|
+
options: { confirmed?: boolean } = {}
|
|
14
|
+
): QuestionTypeChangeResult {
|
|
15
|
+
if (isSubmitTab(state)) {
|
|
16
|
+
return { needsConfirmation: false, state };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const question = getCurrentQuestion(state);
|
|
20
|
+
if (!question) {
|
|
21
|
+
return { needsConfirmation: false, state };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const nextType = getNextQuestionType(question);
|
|
25
|
+
if (nextType === question.type) {
|
|
26
|
+
return { needsConfirmation: false, state };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const answer = getAnswer(state, question.id);
|
|
30
|
+
const requiresConfirmation =
|
|
31
|
+
nextType === "single" && countCommittedAnswers(answer) > 1;
|
|
32
|
+
if (requiresConfirmation && !options.confirmed) {
|
|
33
|
+
return {
|
|
34
|
+
needsConfirmation: true,
|
|
35
|
+
notice:
|
|
36
|
+
"Switching to single-select will clear selected options for this question. Press the type key again to confirm.",
|
|
37
|
+
state,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
needsConfirmation: false,
|
|
43
|
+
notice: getQuestionTypeChangeNotice(question.type, nextType),
|
|
44
|
+
state: setQuestionType(state, question.id, nextType, {
|
|
45
|
+
clearSelectedOptions: requiresConfirmation,
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getNextQuestionType(question: {
|
|
51
|
+
requestedType?: AskQuestionType;
|
|
52
|
+
type: AskQuestionType;
|
|
53
|
+
}): AskQuestionType {
|
|
54
|
+
const requestedType = question.requestedType ?? question.type;
|
|
55
|
+
if (requestedType === "preview") {
|
|
56
|
+
return question.type === "preview" ? "multi" : "preview";
|
|
57
|
+
}
|
|
58
|
+
switch (question.type) {
|
|
59
|
+
case "single":
|
|
60
|
+
return "multi";
|
|
61
|
+
case "multi":
|
|
62
|
+
return "single";
|
|
63
|
+
case "preview":
|
|
64
|
+
return "multi";
|
|
65
|
+
default:
|
|
66
|
+
return "multi";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function countCommittedAnswers(answer: AskStateAnswer | undefined): number {
|
|
71
|
+
if (!answer) {
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
return (
|
|
75
|
+
answer.selected.length +
|
|
76
|
+
(answer.customSelected && answer.customText?.trim() ? 1 : 0)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function setQuestionType(
|
|
81
|
+
state: AskState,
|
|
82
|
+
questionId: string,
|
|
83
|
+
type: AskQuestionType,
|
|
84
|
+
options: { clearSelectedOptions: boolean }
|
|
85
|
+
): AskState {
|
|
86
|
+
const questions = state.questions.map((question) => {
|
|
87
|
+
if (question.id !== questionId) {
|
|
88
|
+
return question;
|
|
89
|
+
}
|
|
90
|
+
const requestedType = question.requestedType ?? question.type;
|
|
91
|
+
return {
|
|
92
|
+
...question,
|
|
93
|
+
type,
|
|
94
|
+
requestedType,
|
|
95
|
+
presentedType: type === requestedType ? undefined : type,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const currentAnswer = state.answers[questionId];
|
|
100
|
+
if (!(options.clearSelectedOptions && currentAnswer)) {
|
|
101
|
+
return { ...state, questions };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const nextAnswer = {
|
|
105
|
+
...currentAnswer,
|
|
106
|
+
customSelected: currentAnswer.customText?.trim() ? true : undefined,
|
|
107
|
+
selected: [],
|
|
108
|
+
};
|
|
109
|
+
const answers = { ...state.answers };
|
|
110
|
+
if (isAnswerEmpty(nextAnswer)) {
|
|
111
|
+
delete answers[questionId];
|
|
112
|
+
} else {
|
|
113
|
+
answers[questionId] = nextAnswer;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
...state,
|
|
118
|
+
questions,
|
|
119
|
+
answers,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getQuestionTypeChangeNotice(
|
|
124
|
+
from: AskQuestionType,
|
|
125
|
+
to: AskQuestionType
|
|
126
|
+
): string {
|
|
127
|
+
return `Question type changed from ${from} to ${to}.`;
|
|
128
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CANCELLED_SUMMARY,
|
|
3
|
+
ELABORATED_SUMMARY,
|
|
4
|
+
ELABORATION_INSTRUCTION,
|
|
5
|
+
SUBMITTED_SUMMARY,
|
|
6
|
+
} from "../constants/text.ts";
|
|
7
|
+
import { formatElaborationLines, formatResultLines } from "../result-format.ts";
|
|
8
|
+
import type {
|
|
9
|
+
AskContinuationPayload,
|
|
10
|
+
AskElaborationPayload,
|
|
11
|
+
AskResult,
|
|
12
|
+
AskResultAnswer,
|
|
13
|
+
AskState,
|
|
14
|
+
AskStateAnswer,
|
|
15
|
+
} from "../types.ts";
|
|
16
|
+
import {
|
|
17
|
+
cloneResultAnswer,
|
|
18
|
+
getExtraOptionNotes,
|
|
19
|
+
hasAnswerNotes,
|
|
20
|
+
isAnswerAnswered,
|
|
21
|
+
isAnswerEmpty,
|
|
22
|
+
isOptionSelected,
|
|
23
|
+
isResultAnswerCommitted,
|
|
24
|
+
isResultAnswerEmpty,
|
|
25
|
+
serializeAnswer,
|
|
26
|
+
} from "./answers.ts";
|
|
27
|
+
import { getQuestionOptionByValue } from "./selectors.ts";
|
|
28
|
+
|
|
29
|
+
export type ReviewAnswer = AskResult["answers"][string] & {
|
|
30
|
+
extraOptionNotes?: Array<{
|
|
31
|
+
label: string;
|
|
32
|
+
note: string;
|
|
33
|
+
}>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function toAskResult(state: AskState): AskResult {
|
|
37
|
+
const answers = Object.fromEntries(
|
|
38
|
+
Object.entries(state.answers)
|
|
39
|
+
.map(
|
|
40
|
+
([questionId, answer]) => [questionId, serializeAnswer(answer)] as const
|
|
41
|
+
)
|
|
42
|
+
.filter(([, answer]) =>
|
|
43
|
+
state.mode === "elaborate"
|
|
44
|
+
? isResultAnswerCommitted(answer)
|
|
45
|
+
: !isResultAnswerEmpty(answer)
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
title: state.title,
|
|
51
|
+
cancelled: state.cancelled,
|
|
52
|
+
mode: state.mode,
|
|
53
|
+
questions: state.questions.map((question) => ({
|
|
54
|
+
id: question.id,
|
|
55
|
+
label: question.label,
|
|
56
|
+
prompt: question.prompt,
|
|
57
|
+
type: question.requestedType ?? question.type,
|
|
58
|
+
...(question.presentedType &&
|
|
59
|
+
question.presentedType !== question.requestedType
|
|
60
|
+
? { presentedType: question.presentedType }
|
|
61
|
+
: {}),
|
|
62
|
+
})),
|
|
63
|
+
answers,
|
|
64
|
+
continuation:
|
|
65
|
+
state.mode === "elaborate"
|
|
66
|
+
? serializeContinuation(state, answers)
|
|
67
|
+
: undefined,
|
|
68
|
+
elaboration:
|
|
69
|
+
state.mode === "elaborate" ? serializeElaboration(state) : undefined,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function summarizeResult(result: AskResult): string {
|
|
74
|
+
if (result.cancelled) {
|
|
75
|
+
return CANCELLED_SUMMARY;
|
|
76
|
+
}
|
|
77
|
+
if (result.mode === "elaborate") {
|
|
78
|
+
const lines = formatElaborationLines(result, { mode: "summary" });
|
|
79
|
+
return lines.join("\n") || ELABORATED_SUMMARY;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const lines = formatResultLines(result, { mode: "summary" });
|
|
83
|
+
return lines.join("\n") || SUBMITTED_SUMMARY;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function hasAnswerContent(state: AskState, questionId: string): boolean {
|
|
87
|
+
const answer = state.answers[questionId];
|
|
88
|
+
return !!answer && !isAnswerEmpty(answer);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function serializeContinuation(
|
|
92
|
+
state: AskState,
|
|
93
|
+
answers: AskResult["answers"]
|
|
94
|
+
): AskContinuationPayload {
|
|
95
|
+
const affectedQuestionIds: string[] = [];
|
|
96
|
+
const preservedAnswers: AskContinuationPayload["preservedAnswers"] = {};
|
|
97
|
+
const questionStates: AskContinuationPayload["questionStates"] = {};
|
|
98
|
+
|
|
99
|
+
for (const question of state.questions) {
|
|
100
|
+
const answer = state.answers[question.id];
|
|
101
|
+
const hasClarificationNeed = hasAnswerNotes(answer);
|
|
102
|
+
const committedAnswer = answers[question.id];
|
|
103
|
+
const answered = !!committedAnswer;
|
|
104
|
+
|
|
105
|
+
if (hasClarificationNeed) {
|
|
106
|
+
affectedQuestionIds.push(question.id);
|
|
107
|
+
questionStates[question.id] = { status: "needs_clarification" };
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (answered) {
|
|
112
|
+
preservedAnswers[question.id] = cloneResultAnswer(committedAnswer);
|
|
113
|
+
questionStates[question.id] = { status: "answered" };
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
questionStates[question.id] = { status: "unanswered" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
affectedQuestionIds,
|
|
122
|
+
preservedAnswers,
|
|
123
|
+
questionStates,
|
|
124
|
+
strategy: "refine_only",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function serializeElaboration(state: AskState): AskElaborationPayload {
|
|
129
|
+
const items = state.questions.flatMap((question) =>
|
|
130
|
+
serializeElaborationItemsForQuestion(question, state.answers[question.id])
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
instruction: ELABORATION_INSTRUCTION,
|
|
135
|
+
nextAction: "clarify_then_reask",
|
|
136
|
+
items,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function serializeElaborationItemsForQuestion(
|
|
141
|
+
question: AskState["questions"][number],
|
|
142
|
+
answer: AskStateAnswer | undefined
|
|
143
|
+
): AskElaborationPayload["items"] {
|
|
144
|
+
if (!(answer && hasAnswerNotes(answer))) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const questionContext = createElaborationQuestionContext(question);
|
|
149
|
+
const serializedAnswer = toCommittedResultAnswer(answer);
|
|
150
|
+
const answered = isAnswerAnswered(answer);
|
|
151
|
+
const items: AskElaborationPayload["items"] = [];
|
|
152
|
+
|
|
153
|
+
if (answer.note) {
|
|
154
|
+
items.push({
|
|
155
|
+
target: { kind: "question" },
|
|
156
|
+
question: questionContext,
|
|
157
|
+
answered,
|
|
158
|
+
answer: serializedAnswer,
|
|
159
|
+
note: answer.note,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const [value, note] of Object.entries(answer.optionNotes ?? {})) {
|
|
164
|
+
const option = getQuestionOptionByValue(question, value);
|
|
165
|
+
if (!(option && note)) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
items.push({
|
|
170
|
+
target: {
|
|
171
|
+
kind: "option",
|
|
172
|
+
optionValue: value,
|
|
173
|
+
},
|
|
174
|
+
question: questionContext,
|
|
175
|
+
option: cloneOption(option),
|
|
176
|
+
selected: isOptionSelected(answer, value),
|
|
177
|
+
answered,
|
|
178
|
+
answer: serializedAnswer,
|
|
179
|
+
note,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return items;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function createElaborationQuestionContext(
|
|
187
|
+
question: AskState["questions"][number]
|
|
188
|
+
) {
|
|
189
|
+
return {
|
|
190
|
+
id: question.id,
|
|
191
|
+
label: question.label,
|
|
192
|
+
prompt: question.prompt,
|
|
193
|
+
type: question.requestedType ?? question.type,
|
|
194
|
+
...(question.presentedType &&
|
|
195
|
+
question.presentedType !== question.requestedType
|
|
196
|
+
? { presentedType: question.presentedType }
|
|
197
|
+
: {}),
|
|
198
|
+
options: question.options.map(cloneOption),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function cloneOption(option: AskState["questions"][number]["options"][number]) {
|
|
203
|
+
return {
|
|
204
|
+
value: option.value,
|
|
205
|
+
label: option.label,
|
|
206
|
+
...(option.description ? { description: option.description } : {}),
|
|
207
|
+
...(option.preview ? { preview: option.preview } : {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function toCommittedResultAnswer(
|
|
212
|
+
answer: AskStateAnswer | undefined
|
|
213
|
+
): AskResultAnswer | undefined {
|
|
214
|
+
if (!(answer && isAnswerAnswered(answer))) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
return cloneResultAnswer(serializeAnswer(answer));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function toReviewAnswer(
|
|
221
|
+
question: AskState["questions"][number],
|
|
222
|
+
answer: AskStateAnswer | undefined,
|
|
223
|
+
showAllNotes: boolean
|
|
224
|
+
): ReviewAnswer | undefined {
|
|
225
|
+
if (!answer) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const serialized = serializeAnswer(answer);
|
|
230
|
+
const hasCommittedAnswer = isResultAnswerCommitted(serialized);
|
|
231
|
+
if (!showAllNotes) {
|
|
232
|
+
return hasCommittedAnswer ? serialized : undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const extraOptionNotes = getExtraOptionNotes({
|
|
236
|
+
answer,
|
|
237
|
+
questionOptions: question.options,
|
|
238
|
+
selectedValues: serialized.values,
|
|
239
|
+
});
|
|
240
|
+
if (
|
|
241
|
+
!(hasCommittedAnswer || serialized.note) &&
|
|
242
|
+
extraOptionNotes.length === 0
|
|
243
|
+
) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
...serialized,
|
|
249
|
+
extraOptionNotes:
|
|
250
|
+
extraOptionNotes.length > 0 ? extraOptionNotes : undefined,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function shouldRenderAnswersIndividually(answer: ReviewAnswer): boolean {
|
|
255
|
+
if (!answer.labels.length) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
answer.labels.length > 1 ||
|
|
261
|
+
Boolean(answer.optionNotes && Object.keys(answer.optionNotes).length > 0)
|
|
262
|
+
);
|
|
263
|
+
}
|