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