pi-interview 0.6.2 → 0.8.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/README.md +5 -3
- package/form/script.js +1230 -327
- package/form/styles.css +332 -17
- package/index.ts +409 -62
- package/package.json +1 -1
- package/server.ts +609 -81
package/server.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { readFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, renameSyn
|
|
|
6
6
|
import { mkdir, writeFile, copyFile } from "node:fs/promises";
|
|
7
7
|
import { execSync } from "node:child_process";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import type
|
|
9
|
+
import { getOptionLabel, type Question, type QuestionsFile, type MediaBlock, type OptionValue } from "./schema.js";
|
|
10
10
|
|
|
11
11
|
function getGitBranch(cwd: string): string | null {
|
|
12
12
|
try {
|
|
@@ -178,12 +178,45 @@ function saveToRecovery(
|
|
|
178
178
|
return filePath;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
export interface ChoiceResponseValue {
|
|
182
|
+
option: string;
|
|
183
|
+
note?: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type ResponseValue = string | string[] | ChoiceResponseValue | ChoiceResponseValue[];
|
|
187
|
+
|
|
181
188
|
export interface ResponseItem {
|
|
182
189
|
id: string;
|
|
183
|
-
value:
|
|
190
|
+
value: ResponseValue;
|
|
184
191
|
attachments?: string[];
|
|
185
192
|
}
|
|
186
193
|
|
|
194
|
+
export interface SavedOptionInsight {
|
|
195
|
+
id: string;
|
|
196
|
+
questionId: string;
|
|
197
|
+
optionKey: string;
|
|
198
|
+
optionText: string;
|
|
199
|
+
prompt: string;
|
|
200
|
+
summary: string;
|
|
201
|
+
bullets?: string[];
|
|
202
|
+
suggestedText?: string;
|
|
203
|
+
modelUsed?: string | null;
|
|
204
|
+
createdAt?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface AskModelOption {
|
|
208
|
+
value: string;
|
|
209
|
+
provider: string;
|
|
210
|
+
label: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface OptionInsightResult {
|
|
214
|
+
summary: string;
|
|
215
|
+
bullets?: string[];
|
|
216
|
+
suggestedText?: string;
|
|
217
|
+
modelUsed?: string | null;
|
|
218
|
+
}
|
|
219
|
+
|
|
187
220
|
export interface InterviewServerOptions {
|
|
188
221
|
questions: QuestionsFile;
|
|
189
222
|
sessionToken: string;
|
|
@@ -196,7 +229,11 @@ export interface InterviewServerOptions {
|
|
|
196
229
|
snapshotDir?: string;
|
|
197
230
|
autoSaveOnSubmit?: boolean;
|
|
198
231
|
savedAnswers?: ResponseItem[];
|
|
232
|
+
savedOptionInsights?: SavedOptionInsight[];
|
|
233
|
+
optionKeysByQuestion?: Record<string, string[]>;
|
|
199
234
|
canGenerate?: boolean;
|
|
235
|
+
askModels?: AskModelOption[];
|
|
236
|
+
defaultAskModel?: string | null;
|
|
200
237
|
}
|
|
201
238
|
|
|
202
239
|
export interface InterviewServerCallbacks {
|
|
@@ -207,7 +244,14 @@ export interface InterviewServerCallbacks {
|
|
|
207
244
|
existingOptions: string[],
|
|
208
245
|
signal: AbortSignal,
|
|
209
246
|
mode: "add" | "review",
|
|
210
|
-
) => Promise<{ options:
|
|
247
|
+
) => Promise<{ options: OptionValue[]; question?: string }>;
|
|
248
|
+
onOptionInsight?: (
|
|
249
|
+
questionId: string,
|
|
250
|
+
option: OptionValue,
|
|
251
|
+
prompt: string,
|
|
252
|
+
modelOverride: string | null,
|
|
253
|
+
signal: AbortSignal,
|
|
254
|
+
) => Promise<OptionInsightResult>;
|
|
211
255
|
}
|
|
212
256
|
|
|
213
257
|
export interface InterviewServerHandle {
|
|
@@ -387,11 +431,12 @@ function ensureQuestionId(
|
|
|
387
431
|
return { ok: true, question };
|
|
388
432
|
}
|
|
389
433
|
|
|
390
|
-
function syncRecommendations(question: Question, options:
|
|
434
|
+
function syncRecommendations(question: Question, options: OptionValue[]): void {
|
|
435
|
+
const optionLabels = options.map((option) => getOptionLabel(option));
|
|
391
436
|
if (!question.recommended) return;
|
|
392
437
|
|
|
393
438
|
if (question.type === "single") {
|
|
394
|
-
if (typeof question.recommended === "string" &&
|
|
439
|
+
if (typeof question.recommended === "string" && optionLabels.includes(question.recommended)) {
|
|
395
440
|
return;
|
|
396
441
|
}
|
|
397
442
|
delete question.recommended;
|
|
@@ -407,7 +452,7 @@ function syncRecommendations(question: Question, options: string[]): void {
|
|
|
407
452
|
|
|
408
453
|
const nextRecommended = (Array.isArray(question.recommended)
|
|
409
454
|
? question.recommended
|
|
410
|
-
: [question.recommended]).filter((option) =>
|
|
455
|
+
: [question.recommended]).filter((option) => optionLabels.includes(option));
|
|
411
456
|
if (nextRecommended.length === 0) {
|
|
412
457
|
delete question.recommended;
|
|
413
458
|
delete question.conviction;
|
|
@@ -416,6 +461,246 @@ function syncRecommendations(question: Question, options: string[]): void {
|
|
|
416
461
|
question.recommended = nextRecommended;
|
|
417
462
|
}
|
|
418
463
|
|
|
464
|
+
function makeOptionKey(): string {
|
|
465
|
+
return `opt-${randomUUID()}`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function buildOptionKeysByQuestion(
|
|
469
|
+
questionsList: Question[],
|
|
470
|
+
initial: Record<string, string[]> | undefined,
|
|
471
|
+
): Record<string, string[]> {
|
|
472
|
+
const next: Record<string, string[]> = {};
|
|
473
|
+
for (const question of questionsList) {
|
|
474
|
+
if (question.type !== "single" && question.type !== "multi") continue;
|
|
475
|
+
if (!question.options) continue;
|
|
476
|
+
const existing = initial?.[question.id];
|
|
477
|
+
if (Array.isArray(existing) && existing.length === question.options.length && existing.every((key) => typeof key === "string" && key.trim().length > 0)) {
|
|
478
|
+
next[question.id] = [...existing];
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
next[question.id] = question.options.map(() => makeOptionKey());
|
|
482
|
+
}
|
|
483
|
+
return next;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function getOptionIndexByKey(
|
|
487
|
+
question: Question,
|
|
488
|
+
optionKeysByQuestion: Record<string, string[]>,
|
|
489
|
+
optionKey: string,
|
|
490
|
+
): number {
|
|
491
|
+
if ((question.type !== "single" && question.type !== "multi") || !question.options) {
|
|
492
|
+
return -1;
|
|
493
|
+
}
|
|
494
|
+
const keys = optionKeysByQuestion[question.id] ?? [];
|
|
495
|
+
return keys.indexOf(optionKey);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function setOptionLabel(option: OptionValue, label: string): OptionValue {
|
|
499
|
+
return typeof option === "string" ? label : { ...option, label };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function isChoiceResponseValue(value: unknown): value is ChoiceResponseValue {
|
|
503
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && typeof (value as ChoiceResponseValue).option === "string";
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function normalizeChoiceResponseValue(value: unknown): ChoiceResponseValue | null {
|
|
507
|
+
if (!isChoiceResponseValue(value)) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
const option = value.option.trim();
|
|
511
|
+
if (!option) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
const note = typeof value.note === "string" ? value.note.trim() : "";
|
|
515
|
+
return note ? { option, note } : { option };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function cloneResponseValue(value: ResponseValue): ResponseValue {
|
|
519
|
+
if (Array.isArray(value)) {
|
|
520
|
+
return value.map((item) => isChoiceResponseValue(item)
|
|
521
|
+
? { ...item }
|
|
522
|
+
: item);
|
|
523
|
+
}
|
|
524
|
+
if (isChoiceResponseValue(value)) {
|
|
525
|
+
return { ...value };
|
|
526
|
+
}
|
|
527
|
+
return value;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function normalizeResponseItem(
|
|
531
|
+
question: Question,
|
|
532
|
+
item: { id?: unknown; value?: unknown; attachments?: unknown },
|
|
533
|
+
): { ok: true; response: ResponseItem } | { ok: false; error: string } {
|
|
534
|
+
const response: ResponseItem = { id: question.id, value: "" };
|
|
535
|
+
|
|
536
|
+
if (question.type === "image") {
|
|
537
|
+
if (Array.isArray(item.value) && item.value.every((value) => typeof value === "string")) {
|
|
538
|
+
response.value = item.value;
|
|
539
|
+
}
|
|
540
|
+
} else if (question.type === "single") {
|
|
541
|
+
if (item.value === "") {
|
|
542
|
+
response.value = "";
|
|
543
|
+
} else {
|
|
544
|
+
const value = normalizeChoiceResponseValue(item.value);
|
|
545
|
+
if (!value) {
|
|
546
|
+
return { ok: false, error: `Invalid response value for ${question.id}` };
|
|
547
|
+
}
|
|
548
|
+
response.value = value;
|
|
549
|
+
}
|
|
550
|
+
} else if (question.type === "multi") {
|
|
551
|
+
if (!Array.isArray(item.value)) {
|
|
552
|
+
return { ok: false, error: `Invalid response value for ${question.id}` };
|
|
553
|
+
}
|
|
554
|
+
const values: ChoiceResponseValue[] = [];
|
|
555
|
+
for (const value of item.value) {
|
|
556
|
+
const normalized = normalizeChoiceResponseValue(value);
|
|
557
|
+
if (!normalized) {
|
|
558
|
+
return { ok: false, error: `Invalid response value for ${question.id}` };
|
|
559
|
+
}
|
|
560
|
+
values.push(normalized);
|
|
561
|
+
}
|
|
562
|
+
response.value = values;
|
|
563
|
+
} else {
|
|
564
|
+
if (typeof item.value !== "string") {
|
|
565
|
+
return { ok: false, error: `Invalid response value for ${question.id}` };
|
|
566
|
+
}
|
|
567
|
+
response.value = item.value;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (Array.isArray(item.attachments) && item.attachments.every((attachment) => typeof attachment === "string")) {
|
|
571
|
+
response.attachments = item.attachments;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return { ok: true, response };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function normalizeResponseItems(
|
|
578
|
+
responsesInput: unknown[],
|
|
579
|
+
questionById: Map<string, Question>,
|
|
580
|
+
): { ok: true; responses: ResponseItem[] } | { ok: false; field?: string; error: string } {
|
|
581
|
+
const responses: ResponseItem[] = [];
|
|
582
|
+
for (const item of responsesInput) {
|
|
583
|
+
if (!item || typeof item !== "object" || typeof (item as { id?: unknown }).id !== "string") continue;
|
|
584
|
+
const typedItem = item as { id: string; value?: unknown; attachments?: unknown };
|
|
585
|
+
const questionCheck = ensureQuestionId(typedItem.id, questionById);
|
|
586
|
+
if (questionCheck.ok === false) {
|
|
587
|
+
return { ok: false, error: questionCheck.error, field: typedItem.id };
|
|
588
|
+
}
|
|
589
|
+
const normalized = normalizeResponseItem(questionCheck.question, typedItem);
|
|
590
|
+
if (normalized.ok === false) {
|
|
591
|
+
return { ok: false, error: normalized.error, field: typedItem.id };
|
|
592
|
+
}
|
|
593
|
+
responses.push(normalized.response);
|
|
594
|
+
}
|
|
595
|
+
return { ok: true, responses };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function normalizeGeneratedOptionValues(options: OptionValue[]): OptionValue[] {
|
|
599
|
+
const uniqueOptions: OptionValue[] = [];
|
|
600
|
+
const indexByLabel = new Map<string, number>();
|
|
601
|
+
for (const option of options) {
|
|
602
|
+
const label = getOptionLabel(option).trim();
|
|
603
|
+
if (!label) continue;
|
|
604
|
+
const normalizedOption = setOptionLabel(option, label);
|
|
605
|
+
const labelKey = label.toLowerCase();
|
|
606
|
+
const existingIndex = indexByLabel.get(labelKey);
|
|
607
|
+
if (existingIndex === undefined) {
|
|
608
|
+
indexByLabel.set(labelKey, uniqueOptions.length);
|
|
609
|
+
uniqueOptions.push(normalizedOption);
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (typeof uniqueOptions[existingIndex] === "string" && typeof normalizedOption !== "string") {
|
|
613
|
+
uniqueOptions[existingIndex] = normalizedOption;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return uniqueOptions;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function reconcileOptionKeysByLabel(
|
|
620
|
+
previousOptions: OptionValue[],
|
|
621
|
+
previousKeys: string[],
|
|
622
|
+
nextOptions: OptionValue[],
|
|
623
|
+
): string[] {
|
|
624
|
+
const availableKeysByLabel = new Map<string, string[]>();
|
|
625
|
+
for (let index = 0; index < previousOptions.length; index++) {
|
|
626
|
+
const key = previousKeys[index];
|
|
627
|
+
if (!key) continue;
|
|
628
|
+
const label = getOptionLabel(previousOptions[index]).trim();
|
|
629
|
+
const existing = availableKeysByLabel.get(label);
|
|
630
|
+
if (existing) {
|
|
631
|
+
existing.push(key);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
availableKeysByLabel.set(label, [key]);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return nextOptions.map((option) => {
|
|
638
|
+
const matchingKeys = availableKeysByLabel.get(getOptionLabel(option).trim());
|
|
639
|
+
if (!matchingKeys || matchingKeys.length === 0) {
|
|
640
|
+
return makeOptionKey();
|
|
641
|
+
}
|
|
642
|
+
return matchingKeys.shift() ?? makeOptionKey();
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function normalizeSavedOptionInsights(input: unknown): SavedOptionInsight[] {
|
|
647
|
+
if (!Array.isArray(input)) return [];
|
|
648
|
+
const normalized: SavedOptionInsight[] = [];
|
|
649
|
+
for (const item of input) {
|
|
650
|
+
if (!item || typeof item !== "object") continue;
|
|
651
|
+
const raw = item as Record<string, unknown>;
|
|
652
|
+
if (
|
|
653
|
+
typeof raw.id !== "string" ||
|
|
654
|
+
typeof raw.questionId !== "string" ||
|
|
655
|
+
typeof raw.optionKey !== "string" ||
|
|
656
|
+
typeof raw.optionText !== "string" ||
|
|
657
|
+
typeof raw.prompt !== "string" ||
|
|
658
|
+
typeof raw.summary !== "string"
|
|
659
|
+
) {
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
const bullets = Array.isArray(raw.bullets)
|
|
663
|
+
? raw.bullets.filter((bullet): bullet is string => typeof bullet === "string" && bullet.trim().length > 0)
|
|
664
|
+
: undefined;
|
|
665
|
+
normalized.push({
|
|
666
|
+
id: raw.id,
|
|
667
|
+
questionId: raw.questionId,
|
|
668
|
+
optionKey: raw.optionKey,
|
|
669
|
+
optionText: raw.optionText,
|
|
670
|
+
prompt: raw.prompt,
|
|
671
|
+
summary: raw.summary,
|
|
672
|
+
bullets: bullets && bullets.length > 0 ? bullets : undefined,
|
|
673
|
+
suggestedText: typeof raw.suggestedText === "string" ? raw.suggestedText : undefined,
|
|
674
|
+
modelUsed: typeof raw.modelUsed === "string" ? raw.modelUsed : raw.modelUsed === null ? null : undefined,
|
|
675
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : undefined,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
return normalized;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function renderSavedOptionInsightsHtml(insights: SavedOptionInsight[]): string {
|
|
682
|
+
if (insights.length === 0) return "";
|
|
683
|
+
return `<div class="saved-option-insights">${insights.map((insight) => {
|
|
684
|
+
const bulletsHtml = insight.bullets && insight.bullets.length > 0
|
|
685
|
+
? `<ul>${insight.bullets.map((bullet) => `<li>${escapeHtml(bullet)}</li>`).join("")}</ul>`
|
|
686
|
+
: "";
|
|
687
|
+
const suggestionHtml = insight.suggestedText
|
|
688
|
+
? `<div class="saved-option-insight-suggestion"><span>Suggested rewrite</span><code>${escapeHtml(insight.suggestedText)}</code></div>`
|
|
689
|
+
: "";
|
|
690
|
+
const metaParts = [
|
|
691
|
+
insight.optionText ? `Option: ${escapeHtml(insight.optionText)}` : "",
|
|
692
|
+
insight.modelUsed ? `Model: ${escapeHtml(insight.modelUsed)}` : "",
|
|
693
|
+
].filter(Boolean);
|
|
694
|
+
return `<article class="saved-option-insight">
|
|
695
|
+
<div class="saved-option-insight-prompt">${escapeHtml(insight.prompt)}</div>
|
|
696
|
+
${metaParts.length > 0 ? `<div class="saved-option-insight-meta">${metaParts.join(" · ")}</div>` : ""}
|
|
697
|
+
<p>${escapeHtml(insight.summary)}</p>
|
|
698
|
+
${bulletsHtml}
|
|
699
|
+
${suggestionHtml}
|
|
700
|
+
</article>`;
|
|
701
|
+
}).join("")}</div>`;
|
|
702
|
+
}
|
|
703
|
+
|
|
419
704
|
// HTML generation for saved interviews
|
|
420
705
|
interface SavedFromMeta {
|
|
421
706
|
cwd: string;
|
|
@@ -645,6 +930,13 @@ function savedAnswerItemHtml(text: string, q: Question): string {
|
|
|
645
930
|
return escapeHtml(text) + indicator;
|
|
646
931
|
}
|
|
647
932
|
|
|
933
|
+
function savedChoiceAnswerHtml(value: ChoiceResponseValue, question: Question): string {
|
|
934
|
+
const noteHtml = value.note
|
|
935
|
+
? `<div class="saved-answer-note">${escapeHtml(value.note)}</div>`
|
|
936
|
+
: "";
|
|
937
|
+
return `<div class="saved-answer-choice">${savedAnswerItemHtml(value.option, question)}${noteHtml}</div>`;
|
|
938
|
+
}
|
|
939
|
+
|
|
648
940
|
function weightClasses(q: Question): string {
|
|
649
941
|
const classes = ["saved-question"];
|
|
650
942
|
if (q.type === "info") classes.push("info-panel");
|
|
@@ -683,7 +975,11 @@ async function copyMediaImages(questionsList: Question[], imagesDir: string, cwd
|
|
|
683
975
|
return rewritten;
|
|
684
976
|
}
|
|
685
977
|
|
|
686
|
-
function renderQuestionsHtml(
|
|
978
|
+
function renderQuestionsHtml(
|
|
979
|
+
questionsList: Question[],
|
|
980
|
+
answers: ResponseItem[],
|
|
981
|
+
optionInsights: SavedOptionInsight[],
|
|
982
|
+
): string {
|
|
687
983
|
const answerMap = new Map(answers.map((a) => [a.id, a]));
|
|
688
984
|
let questionNum = 0;
|
|
689
985
|
return questionsList
|
|
@@ -692,6 +988,9 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
|
|
|
692
988
|
if (showNumber) questionNum++;
|
|
693
989
|
const numPrefix = showNumber ? `${questionNum}. ` : "";
|
|
694
990
|
const mediaHtml = renderMediaListHtml(q.media);
|
|
991
|
+
const optionInsightsHtml = renderSavedOptionInsightsHtml(
|
|
992
|
+
optionInsights.filter((insight) => insight.questionId === q.id),
|
|
993
|
+
);
|
|
695
994
|
|
|
696
995
|
if (q.type === "info") {
|
|
697
996
|
const contentHtml = renderContentBlockHtml(q.content);
|
|
@@ -701,6 +1000,7 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
|
|
|
701
1000
|
${q.context ? `<p class="question-context">${escapeHtml(q.context)}</p>` : ""}
|
|
702
1001
|
${contentHtml}
|
|
703
1002
|
${mediaHtml}
|
|
1003
|
+
${optionInsightsHtml}
|
|
704
1004
|
</div>
|
|
705
1005
|
`;
|
|
706
1006
|
}
|
|
@@ -718,10 +1018,17 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
|
|
|
718
1018
|
.map((p) => `<img src="${escapeHtml(p)}" alt="uploaded image">`)
|
|
719
1019
|
.join("")}</div>`;
|
|
720
1020
|
} else if (q.type === "multi") {
|
|
721
|
-
const items = Array.isArray(value) ? value : [
|
|
1021
|
+
const items = Array.isArray(value) ? value.filter(isChoiceResponseValue) : [];
|
|
722
1022
|
answerHtml = `<div class="saved-answer"><ul>${items
|
|
723
|
-
.map((
|
|
1023
|
+
.map((item) => `<li>${savedChoiceAnswerHtml(item, q)}</li>`)
|
|
724
1024
|
.join("")}</ul></div>`;
|
|
1025
|
+
if (items.length === 0) {
|
|
1026
|
+
answerHtml = '<div class="saved-answer empty">(no answer)</div>';
|
|
1027
|
+
}
|
|
1028
|
+
} else if (q.type === "single") {
|
|
1029
|
+
answerHtml = isChoiceResponseValue(value)
|
|
1030
|
+
? `<div class="saved-answer">${savedChoiceAnswerHtml(value, q)}</div>`
|
|
1031
|
+
: '<div class="saved-answer empty">(no answer)</div>';
|
|
725
1032
|
} else {
|
|
726
1033
|
answerHtml = `<div class="saved-answer">${savedAnswerItemHtml(String(value), q)}</div>`;
|
|
727
1034
|
}
|
|
@@ -745,6 +1052,7 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
|
|
|
745
1052
|
${contextHtml}
|
|
746
1053
|
${contentHtml}
|
|
747
1054
|
${mediaHtml}
|
|
1055
|
+
${optionInsightsHtml}
|
|
748
1056
|
${answerHtml}
|
|
749
1057
|
${attachHtml}
|
|
750
1058
|
</div>
|
|
@@ -818,6 +1126,55 @@ const SAVED_VIEW_STYLES = `
|
|
|
818
1126
|
border-radius: var(--radius);
|
|
819
1127
|
white-space: pre-wrap;
|
|
820
1128
|
}
|
|
1129
|
+
.saved-option-insights {
|
|
1130
|
+
display: grid;
|
|
1131
|
+
gap: 10px;
|
|
1132
|
+
margin: 14px 0;
|
|
1133
|
+
}
|
|
1134
|
+
.saved-option-insight {
|
|
1135
|
+
border: 1px solid var(--border-muted);
|
|
1136
|
+
border-radius: 12px;
|
|
1137
|
+
padding: 12px;
|
|
1138
|
+
background: color-mix(in srgb, var(--bg-body) 82%, transparent);
|
|
1139
|
+
}
|
|
1140
|
+
.saved-option-insight-prompt {
|
|
1141
|
+
font-family: var(--font-mono);
|
|
1142
|
+
font-size: 11px;
|
|
1143
|
+
color: var(--accent);
|
|
1144
|
+
margin-bottom: 4px;
|
|
1145
|
+
}
|
|
1146
|
+
.saved-option-insight-meta {
|
|
1147
|
+
font-size: 11px;
|
|
1148
|
+
color: var(--fg-muted);
|
|
1149
|
+
margin-bottom: 8px;
|
|
1150
|
+
}
|
|
1151
|
+
.saved-option-insight p {
|
|
1152
|
+
margin: 0;
|
|
1153
|
+
}
|
|
1154
|
+
.saved-option-insight ul {
|
|
1155
|
+
margin: 8px 0 0;
|
|
1156
|
+
padding-left: 18px;
|
|
1157
|
+
}
|
|
1158
|
+
.saved-option-insight-suggestion {
|
|
1159
|
+
margin-top: 10px;
|
|
1160
|
+
display: grid;
|
|
1161
|
+
gap: 4px;
|
|
1162
|
+
}
|
|
1163
|
+
.saved-option-insight-suggestion span {
|
|
1164
|
+
font-size: 10px;
|
|
1165
|
+
text-transform: uppercase;
|
|
1166
|
+
letter-spacing: 0.06em;
|
|
1167
|
+
color: var(--fg-muted);
|
|
1168
|
+
}
|
|
1169
|
+
.saved-option-insight-suggestion code {
|
|
1170
|
+
display: block;
|
|
1171
|
+
padding: 8px 10px;
|
|
1172
|
+
border-radius: 8px;
|
|
1173
|
+
background: var(--bg-body);
|
|
1174
|
+
font-family: var(--font-mono);
|
|
1175
|
+
white-space: pre-wrap;
|
|
1176
|
+
overflow-wrap: anywhere;
|
|
1177
|
+
}
|
|
821
1178
|
.saved-answer.empty {
|
|
822
1179
|
color: var(--fg-dim);
|
|
823
1180
|
font-style: italic;
|
|
@@ -826,6 +1183,14 @@ const SAVED_VIEW_STYLES = `
|
|
|
826
1183
|
margin: 0;
|
|
827
1184
|
padding-left: 20px;
|
|
828
1185
|
}
|
|
1186
|
+
.saved-answer-choice {
|
|
1187
|
+
display: grid;
|
|
1188
|
+
gap: 4px;
|
|
1189
|
+
}
|
|
1190
|
+
.saved-answer-note {
|
|
1191
|
+
color: var(--fg-muted);
|
|
1192
|
+
font-size: 12px;
|
|
1193
|
+
}
|
|
829
1194
|
.saved-images, .saved-attachments {
|
|
830
1195
|
margin-top: 12px;
|
|
831
1196
|
display: flex;
|
|
@@ -869,11 +1234,13 @@ const SAVED_VIEW_STYLES = `
|
|
|
869
1234
|
function generateSavedHtml(options: {
|
|
870
1235
|
questions: QuestionsFile;
|
|
871
1236
|
answers: ResponseItem[];
|
|
1237
|
+
optionInsights: SavedOptionInsight[];
|
|
1238
|
+
optionKeysByQuestion: Record<string, string[]>;
|
|
872
1239
|
meta: SavedInterviewMeta;
|
|
873
1240
|
baseStyles: string;
|
|
874
1241
|
themeCss: string;
|
|
875
1242
|
}): string {
|
|
876
|
-
const { questions: questionsData, answers, meta, baseStyles, themeCss } = options;
|
|
1243
|
+
const { questions: questionsData, answers, optionInsights, optionKeysByQuestion, meta, baseStyles, themeCss } = options;
|
|
877
1244
|
const title = questionsData.title || "Interview";
|
|
878
1245
|
|
|
879
1246
|
// Build the data object for embedding
|
|
@@ -882,13 +1249,15 @@ function generateSavedHtml(options: {
|
|
|
882
1249
|
description: questionsData.description,
|
|
883
1250
|
questions: questionsData.questions,
|
|
884
1251
|
savedAnswers: answers,
|
|
1252
|
+
savedOptionInsights: optionInsights,
|
|
1253
|
+
optionKeysByQuestion,
|
|
885
1254
|
savedAt: meta.savedAt,
|
|
886
1255
|
wasSubmitted: meta.wasSubmitted,
|
|
887
1256
|
savedFrom: meta.savedFrom,
|
|
888
1257
|
};
|
|
889
1258
|
|
|
890
1259
|
const embeddedJson = safeInlineJSON(dataForEmbedding);
|
|
891
|
-
const questionsHtml = renderQuestionsHtml(questionsData.questions, answers);
|
|
1260
|
+
const questionsHtml = renderQuestionsHtml(questionsData.questions, answers, optionInsights);
|
|
892
1261
|
const savedDate = new Date(meta.savedAt).toLocaleString();
|
|
893
1262
|
const statusClass = meta.wasSubmitted ? "submitted" : "draft";
|
|
894
1263
|
const statusText = meta.wasSubmitted ? "Submitted" : "Draft";
|
|
@@ -936,6 +1305,8 @@ export async function startInterviewServer(
|
|
|
936
1305
|
for (const question of questions.questions) {
|
|
937
1306
|
questionById.set(question.id, question);
|
|
938
1307
|
}
|
|
1308
|
+
const optionKeysByQuestion = buildOptionKeysByQuestion(questions.questions, options.optionKeysByQuestion);
|
|
1309
|
+
const initialSavedOptionInsights = normalizeSavedOptionInsights(options.savedOptionInsights);
|
|
939
1310
|
|
|
940
1311
|
function getMediaList(q: Question): MediaBlock[] {
|
|
941
1312
|
if (!q.media) return [];
|
|
@@ -1065,8 +1436,12 @@ export async function startInterviewServer(
|
|
|
1065
1436
|
toggleHotkey: themeConfig.toggleHotkey,
|
|
1066
1437
|
},
|
|
1067
1438
|
savedAnswers: options.savedAnswers,
|
|
1439
|
+
savedOptionInsights: initialSavedOptionInsights,
|
|
1440
|
+
optionKeysByQuestion,
|
|
1068
1441
|
autoSaveOnSubmit: options.autoSaveOnSubmit ?? true,
|
|
1069
1442
|
canGenerate: options.canGenerate ?? false,
|
|
1443
|
+
askModels: options.askModels ?? [],
|
|
1444
|
+
defaultAskModel: options.defaultAskModel ?? null,
|
|
1070
1445
|
});
|
|
1071
1446
|
const html = TEMPLATE
|
|
1072
1447
|
.replace("<!-- __CDN_SCRIPTS__ -->", cdnScripts)
|
|
@@ -1228,7 +1603,7 @@ export async function startInterviewServer(
|
|
|
1228
1603
|
}
|
|
1229
1604
|
|
|
1230
1605
|
const payload = body as {
|
|
1231
|
-
responses?:
|
|
1606
|
+
responses?: unknown[];
|
|
1232
1607
|
images?: Array<{ id: string; filename: string; mimeType: string; data: string; isAttachment?: boolean }>;
|
|
1233
1608
|
};
|
|
1234
1609
|
|
|
@@ -1240,50 +1615,16 @@ export async function startInterviewServer(
|
|
|
1240
1615
|
return;
|
|
1241
1616
|
}
|
|
1242
1617
|
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
const question = questionCheck.question;
|
|
1252
|
-
|
|
1253
|
-
const resp: ResponseItem = { id: item.id, value: "" };
|
|
1254
|
-
|
|
1255
|
-
if (question.type === "image") {
|
|
1256
|
-
if (Array.isArray(item.value) && item.value.every((v) => typeof v === "string")) {
|
|
1257
|
-
resp.value = item.value;
|
|
1258
|
-
}
|
|
1259
|
-
} else if (question.type === "multi") {
|
|
1260
|
-
if (!Array.isArray(item.value) || item.value.some((v) => typeof v !== "string")) {
|
|
1261
|
-
sendJson(res, 400, {
|
|
1262
|
-
ok: false,
|
|
1263
|
-
error: `Invalid response value for ${item.id}`,
|
|
1264
|
-
field: item.id,
|
|
1265
|
-
});
|
|
1266
|
-
return;
|
|
1267
|
-
}
|
|
1268
|
-
resp.value = item.value;
|
|
1269
|
-
} else {
|
|
1270
|
-
if (typeof item.value !== "string") {
|
|
1271
|
-
sendJson(res, 400, {
|
|
1272
|
-
ok: false,
|
|
1273
|
-
error: `Invalid response value for ${item.id}`,
|
|
1274
|
-
field: item.id,
|
|
1275
|
-
});
|
|
1276
|
-
return;
|
|
1277
|
-
}
|
|
1278
|
-
resp.value = item.value;
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
if (Array.isArray(item.attachments) && item.attachments.every((a) => typeof a === "string")) {
|
|
1282
|
-
resp.attachments = item.attachments;
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
responses.push(resp);
|
|
1618
|
+
const normalizedResponses = normalizeResponseItems(responsesInput, questionById);
|
|
1619
|
+
if (normalizedResponses.ok === false) {
|
|
1620
|
+
sendJson(res, 400, {
|
|
1621
|
+
ok: false,
|
|
1622
|
+
error: normalizedResponses.error,
|
|
1623
|
+
field: normalizedResponses.field,
|
|
1624
|
+
});
|
|
1625
|
+
return;
|
|
1286
1626
|
}
|
|
1627
|
+
const responses = normalizedResponses.responses;
|
|
1287
1628
|
|
|
1288
1629
|
for (const image of imagesInput) {
|
|
1289
1630
|
if (!image || typeof image.id !== "string") continue;
|
|
@@ -1356,7 +1697,8 @@ export async function startInterviewServer(
|
|
|
1356
1697
|
// Note: don't check `completed` - allow save after submit
|
|
1357
1698
|
|
|
1358
1699
|
const payload = body as {
|
|
1359
|
-
responses?:
|
|
1700
|
+
responses?: unknown[];
|
|
1701
|
+
savedOptionInsights?: SavedOptionInsight[];
|
|
1360
1702
|
images?: Array<{
|
|
1361
1703
|
id: string;
|
|
1362
1704
|
filename: string;
|
|
@@ -1368,8 +1710,18 @@ export async function startInterviewServer(
|
|
|
1368
1710
|
};
|
|
1369
1711
|
|
|
1370
1712
|
const responsesInput = Array.isArray(payload.responses) ? payload.responses : [];
|
|
1713
|
+
const savedOptionInsights = normalizeSavedOptionInsights(payload.savedOptionInsights);
|
|
1371
1714
|
const imagesInput = Array.isArray(payload.images) ? payload.images : [];
|
|
1372
1715
|
const submitted = payload.submitted === true;
|
|
1716
|
+
const normalizedResponses = normalizeResponseItems(responsesInput, questionById);
|
|
1717
|
+
if (normalizedResponses.ok === false) {
|
|
1718
|
+
sendJson(res, 400, {
|
|
1719
|
+
ok: false,
|
|
1720
|
+
error: normalizedResponses.error,
|
|
1721
|
+
field: normalizedResponses.field,
|
|
1722
|
+
});
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1373
1725
|
|
|
1374
1726
|
const snapshotBaseDir = options.snapshotDir ?? SNAPSHOTS_DIR;
|
|
1375
1727
|
|
|
@@ -1389,10 +1741,10 @@ export async function startInterviewServer(
|
|
|
1389
1741
|
await mkdir(snapshotPath, { recursive: true });
|
|
1390
1742
|
|
|
1391
1743
|
// Process responses - make a deep copy to avoid mutating input
|
|
1392
|
-
const savedResponses: ResponseItem[] =
|
|
1393
|
-
...
|
|
1394
|
-
value:
|
|
1395
|
-
attachments:
|
|
1744
|
+
const savedResponses: ResponseItem[] = normalizedResponses.responses.map((response) => ({
|
|
1745
|
+
...response,
|
|
1746
|
+
value: cloneResponseValue(response.value),
|
|
1747
|
+
attachments: response.attachments ? [...response.attachments] : undefined,
|
|
1396
1748
|
}));
|
|
1397
1749
|
|
|
1398
1750
|
// Process uploaded images - save to images/ subfolder
|
|
@@ -1451,6 +1803,8 @@ export async function startInterviewServer(
|
|
|
1451
1803
|
const html = generateSavedHtml({
|
|
1452
1804
|
questions: snapshotQuestions,
|
|
1453
1805
|
answers: savedResponses,
|
|
1806
|
+
optionInsights: savedOptionInsights,
|
|
1807
|
+
optionKeysByQuestion,
|
|
1454
1808
|
meta,
|
|
1455
1809
|
baseStyles: STYLES,
|
|
1456
1810
|
themeCss,
|
|
@@ -1482,7 +1836,6 @@ export async function startInterviewServer(
|
|
|
1482
1836
|
|
|
1483
1837
|
const payload = body as {
|
|
1484
1838
|
questionId?: string;
|
|
1485
|
-
existingOptions?: string[];
|
|
1486
1839
|
mode?: string;
|
|
1487
1840
|
};
|
|
1488
1841
|
|
|
@@ -1496,14 +1849,8 @@ export async function startInterviewServer(
|
|
|
1496
1849
|
sendJson(res, 400, { ok: false, error: "Invalid question for generation" });
|
|
1497
1850
|
return;
|
|
1498
1851
|
}
|
|
1499
|
-
if (question.options.some((option) => typeof option !== "string")) {
|
|
1500
|
-
sendJson(res, 400, { ok: false, error: "Generation is not available for rich options" });
|
|
1501
|
-
return;
|
|
1502
|
-
}
|
|
1503
1852
|
|
|
1504
|
-
const existingOptions =
|
|
1505
|
-
? payload.existingOptions.filter((o): o is string => typeof o === "string")
|
|
1506
|
-
: [];
|
|
1853
|
+
const existingOptions = question.options.map((option) => getOptionLabel(option).trim());
|
|
1507
1854
|
|
|
1508
1855
|
const mode = payload.mode === "review" ? "review" : "add";
|
|
1509
1856
|
|
|
@@ -1521,34 +1868,40 @@ export async function startInterviewServer(
|
|
|
1521
1868
|
mode,
|
|
1522
1869
|
);
|
|
1523
1870
|
|
|
1524
|
-
const uniqueOptions
|
|
1525
|
-
const seenOptions = new Set<string>();
|
|
1526
|
-
for (const option of result.options) {
|
|
1527
|
-
const trimmed = option.trim();
|
|
1528
|
-
if (!trimmed) continue;
|
|
1529
|
-
const key = trimmed.toLowerCase();
|
|
1530
|
-
if (seenOptions.has(key)) continue;
|
|
1531
|
-
seenOptions.add(key);
|
|
1532
|
-
uniqueOptions.push(trimmed);
|
|
1533
|
-
}
|
|
1871
|
+
const uniqueOptions = normalizeGeneratedOptionValues(result.options);
|
|
1534
1872
|
|
|
1535
1873
|
const reviewedQuestion = typeof result.question === "string" ? result.question.trim() : undefined;
|
|
1536
1874
|
const storedQuestion = questions.questions.find((q) => q.id === payload.questionId);
|
|
1875
|
+
let nextOptionKeys = optionKeysByQuestion[payload.questionId] ?? [];
|
|
1876
|
+
let appliedOptions: OptionValue[] = [];
|
|
1537
1877
|
if (storedQuestion) {
|
|
1538
1878
|
if (mode === "review" && reviewedQuestion && uniqueOptions.length > 0) {
|
|
1879
|
+
const previousOptions = [...storedQuestion.options];
|
|
1880
|
+
const previousKeys = [...(optionKeysByQuestion[payload.questionId] ?? [])];
|
|
1539
1881
|
storedQuestion.question = reviewedQuestion;
|
|
1540
1882
|
storedQuestion.options = uniqueOptions;
|
|
1541
1883
|
syncRecommendations(storedQuestion, uniqueOptions);
|
|
1884
|
+
nextOptionKeys = reconcileOptionKeysByLabel(previousOptions, previousKeys, uniqueOptions);
|
|
1885
|
+
optionKeysByQuestion[payload.questionId] = nextOptionKeys;
|
|
1886
|
+
appliedOptions = uniqueOptions;
|
|
1542
1887
|
} else if (mode === "add") {
|
|
1543
|
-
const existingKeys = new Set(
|
|
1544
|
-
const newOptions = uniqueOptions.filter((option) => !existingKeys.has(option.toLowerCase()));
|
|
1888
|
+
const existingKeys = new Set(storedQuestion.options.map((option) => getOptionLabel(option).trim().toLowerCase()));
|
|
1889
|
+
const newOptions = uniqueOptions.filter((option) => !existingKeys.has(getOptionLabel(option).trim().toLowerCase()));
|
|
1545
1890
|
if (newOptions.length > 0) {
|
|
1546
1891
|
storedQuestion.options = storedQuestion.options.concat(newOptions);
|
|
1892
|
+
nextOptionKeys = (optionKeysByQuestion[payload.questionId] ?? []).concat(newOptions.map(() => makeOptionKey()));
|
|
1893
|
+
optionKeysByQuestion[payload.questionId] = nextOptionKeys;
|
|
1547
1894
|
}
|
|
1895
|
+
appliedOptions = newOptions;
|
|
1548
1896
|
}
|
|
1549
1897
|
}
|
|
1550
1898
|
|
|
1551
|
-
sendJson(res, 200, {
|
|
1899
|
+
sendJson(res, 200, {
|
|
1900
|
+
ok: true,
|
|
1901
|
+
options: appliedOptions,
|
|
1902
|
+
question: reviewedQuestion,
|
|
1903
|
+
optionKeys: nextOptionKeys,
|
|
1904
|
+
});
|
|
1552
1905
|
} catch (err) {
|
|
1553
1906
|
if (controller.signal.aborted) {
|
|
1554
1907
|
sendJson(res, 409, { ok: false, error: "Request cancelled" });
|
|
@@ -1560,6 +1913,181 @@ export async function startInterviewServer(
|
|
|
1560
1913
|
return;
|
|
1561
1914
|
}
|
|
1562
1915
|
|
|
1916
|
+
if (method === "POST" && url.pathname === "/option-insight") {
|
|
1917
|
+
const body = await parseBodyOrRespond();
|
|
1918
|
+
if (!body) return;
|
|
1919
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
1920
|
+
if (completed) {
|
|
1921
|
+
sendJson(res, 409, { ok: false, error: "Session closed" });
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
if (!callbacks.onOptionInsight) {
|
|
1926
|
+
sendJson(res, 501, { ok: false, error: "Option insight not available" });
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
const payload = body as {
|
|
1931
|
+
questionId?: string;
|
|
1932
|
+
optionKey?: string;
|
|
1933
|
+
prompt?: string;
|
|
1934
|
+
model?: string | null;
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
if (typeof payload.questionId !== "string") {
|
|
1938
|
+
sendJson(res, 400, { ok: false, error: "Missing questionId" });
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
if (typeof payload.optionKey !== "string") {
|
|
1942
|
+
sendJson(res, 400, { ok: false, error: "Missing optionKey" });
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
if (typeof payload.prompt !== "string" || payload.prompt.trim().length === 0) {
|
|
1946
|
+
sendJson(res, 400, { ok: false, error: "Prompt is required" });
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const questionCheck = ensureQuestionId(payload.questionId, questionById);
|
|
1951
|
+
if (questionCheck.ok === false) {
|
|
1952
|
+
sendJson(res, 400, { ok: false, error: questionCheck.error });
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
const question = questionCheck.question;
|
|
1956
|
+
const optionIndex = getOptionIndexByKey(question, optionKeysByQuestion, payload.optionKey);
|
|
1957
|
+
if (optionIndex === -1 || !question.options || optionIndex >= question.options.length) {
|
|
1958
|
+
sendJson(res, 400, { ok: false, error: "Invalid option for insight" });
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
const controller = new AbortController();
|
|
1963
|
+
res.on("close", () => {
|
|
1964
|
+
if (!res.writableEnded) controller.abort();
|
|
1965
|
+
});
|
|
1966
|
+
touchHeartbeat();
|
|
1967
|
+
|
|
1968
|
+
try {
|
|
1969
|
+
const option = question.options[optionIndex];
|
|
1970
|
+
const optionText = getOptionLabel(option);
|
|
1971
|
+
const result = await callbacks.onOptionInsight(
|
|
1972
|
+
payload.questionId,
|
|
1973
|
+
option,
|
|
1974
|
+
payload.prompt.trim(),
|
|
1975
|
+
typeof payload.model === "string" ? payload.model : null,
|
|
1976
|
+
controller.signal,
|
|
1977
|
+
);
|
|
1978
|
+
sendJson(res, 200, { ok: true, optionText, ...result });
|
|
1979
|
+
} catch (err) {
|
|
1980
|
+
if (controller.signal.aborted) {
|
|
1981
|
+
sendJson(res, 409, { ok: false, error: "Request cancelled" });
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
const message = err instanceof Error ? err.message : "Option insight failed";
|
|
1985
|
+
sendJson(res, 500, { ok: false, error: message });
|
|
1986
|
+
}
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
if (method === "POST" && url.pathname === "/option-action") {
|
|
1991
|
+
const body = await parseBodyOrRespond();
|
|
1992
|
+
if (!body) return;
|
|
1993
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
1994
|
+
if (completed) {
|
|
1995
|
+
sendJson(res, 409, { ok: false, error: "Session closed" });
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
const payload = body as {
|
|
2000
|
+
questionId?: string;
|
|
2001
|
+
optionKey?: string;
|
|
2002
|
+
action?: string;
|
|
2003
|
+
text?: string;
|
|
2004
|
+
};
|
|
2005
|
+
|
|
2006
|
+
if (typeof payload.questionId !== "string") {
|
|
2007
|
+
sendJson(res, 400, { ok: false, error: "Missing questionId" });
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
if (typeof payload.optionKey !== "string") {
|
|
2011
|
+
sendJson(res, 400, { ok: false, error: "Missing optionKey" });
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
touchHeartbeat();
|
|
2015
|
+
|
|
2016
|
+
const questionCheck = ensureQuestionId(payload.questionId, questionById);
|
|
2017
|
+
if (questionCheck.ok === false) {
|
|
2018
|
+
sendJson(res, 400, { ok: false, error: questionCheck.error });
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
const question = questionCheck.question;
|
|
2022
|
+
if ((question.type !== "single" && question.type !== "multi") || !question.options) {
|
|
2023
|
+
sendJson(res, 400, { ok: false, error: "Invalid question for option actions" });
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
const optionIndex = getOptionIndexByKey(question, optionKeysByQuestion, payload.optionKey);
|
|
2028
|
+
if (optionIndex === -1) {
|
|
2029
|
+
sendJson(res, 400, { ok: false, error: "Invalid option for action" });
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
const currentOptions = [...question.options];
|
|
2034
|
+
const currentKeys = [...(optionKeysByQuestion[question.id] ?? [])];
|
|
2035
|
+
const action = payload.action;
|
|
2036
|
+
const rawText = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
2037
|
+
|
|
2038
|
+
if (action === "move-up") {
|
|
2039
|
+
if (optionIndex === 0) {
|
|
2040
|
+
sendJson(res, 400, { ok: false, error: "Option is already at the top" });
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
[currentOptions[optionIndex - 1], currentOptions[optionIndex]] = [currentOptions[optionIndex], currentOptions[optionIndex - 1]];
|
|
2044
|
+
[currentKeys[optionIndex - 1], currentKeys[optionIndex]] = [currentKeys[optionIndex], currentKeys[optionIndex - 1]];
|
|
2045
|
+
} else if (action === "replace-text") {
|
|
2046
|
+
if (!rawText) {
|
|
2047
|
+
sendJson(res, 400, { ok: false, error: "Replacement text is required" });
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
const duplicateIndex = currentOptions.findIndex((option, index) => index !== optionIndex && getOptionLabel(option).trim().toLowerCase() === rawText.toLowerCase());
|
|
2051
|
+
if (duplicateIndex !== -1) {
|
|
2052
|
+
sendJson(res, 400, { ok: false, error: "An option with that text already exists" });
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
const previousText = getOptionLabel(currentOptions[optionIndex]);
|
|
2056
|
+
currentOptions[optionIndex] = setOptionLabel(currentOptions[optionIndex], rawText);
|
|
2057
|
+
if (question.type === "single" && question.recommended === previousText) {
|
|
2058
|
+
question.recommended = rawText;
|
|
2059
|
+
} else if (question.type === "multi" && Array.isArray(question.recommended)) {
|
|
2060
|
+
question.recommended = question.recommended.map((option) => option === previousText ? rawText : option);
|
|
2061
|
+
}
|
|
2062
|
+
} else if (action === "add-option") {
|
|
2063
|
+
if (!rawText) {
|
|
2064
|
+
sendJson(res, 400, { ok: false, error: "New option text is required" });
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
if (currentOptions.some((option) => getOptionLabel(option).trim().toLowerCase() === rawText.toLowerCase())) {
|
|
2068
|
+
sendJson(res, 400, { ok: false, error: "An option with that text already exists" });
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
const newOptionKey = makeOptionKey();
|
|
2072
|
+
currentOptions.splice(optionIndex + 1, 0, rawText);
|
|
2073
|
+
currentKeys.splice(optionIndex + 1, 0, newOptionKey);
|
|
2074
|
+
} else {
|
|
2075
|
+
sendJson(res, 400, { ok: false, error: "Unsupported option action" });
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
question.options = currentOptions;
|
|
2080
|
+
optionKeysByQuestion[question.id] = currentKeys;
|
|
2081
|
+
syncRecommendations(question, currentOptions);
|
|
2082
|
+
|
|
2083
|
+
sendJson(res, 200, {
|
|
2084
|
+
ok: true,
|
|
2085
|
+
question,
|
|
2086
|
+
optionKeys: currentKeys,
|
|
2087
|
+
});
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
1563
2091
|
sendText(res, 404, "Not found");
|
|
1564
2092
|
} catch (err) {
|
|
1565
2093
|
const message = err instanceof Error ? err.message : "Server error";
|